Repository: railwayapp/nixpacks
Pull request #638 (2022-10-28 04:27:28)

Experimental Support for Multiple Providers
This PR adds experimental support for generating a build plan that uses multiple providers. The providers must explicitly be set in the `nixpacks.toml` file. Extending the auto detected provider is supported with the `"..."` syntax.

### Phase names

The phases from the first provider in the list will keep the same names that they were created with. Subsequent providers phases will be prefixed with the name of the provider (e.g. `rust:setup`). This is done since phase names cannot have collisions as well as to correctly sort the phase build order. The rust build phase does not depend on the node install phase, and prefixing the phase names ensures that dependency isn't created.

One downside of this approach is that to override the behaviour of a specific non-first phase, you have to do something like
```toml
[phases."rust:build"]
cmds = ["..."]
```

which is a bit awkward. This is only because of allowed characters in the toml language. If the config format is Json, then it is a bit cleaner.

Suggestions of another prefix character besides `:` is welcome.

### Examples

This plan will use **only** the node and rust providers

```toml
providers = ["node", "rust"]
```

This plan will use the auto detected provider and python

```toml
providers = ["...", "python"]
```

### Why is this experimental

While testing this, I've noticed that many of the providers do not handle when specific language files (e.g. package.json) are not found when creating the build plan. Install, build, and start phases should only be added to the plan when appropriate. Some providers assume that since it is running, then there will always be an install, build, or start phases. These kinks will be discovered through use of multiple providers in different app scenarios. When using multiple providers, there is a warning that there might be some bugs.

![image](https://user-images.githubusercontent.com/3044853/198502969-dc7bc208-55df-4775-bc19-8ae8f5a6ae8f.png)



Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 8d7b952..c082980 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -352,7 +352,7 @@ dependencies = [
  "bitflags",
  "clap_lex",
  "indexmap",
- "textwrap",
+ "textwrap 0.15.1",
 ]
 
 [[package]]
@@ -914,7 +914,7 @@ dependencies = [
 
 [[package]]
 name = "nixpacks"
-version = "0.11.5"
+version = "0.11.6"
 dependencies = [
  "actix-web",
  "anyhow",
@@ -942,7 +942,7 @@ dependencies = [
  "serde_yaml",
  "tempdir",
  "test-helper",
- "textwrap",
+ "textwrap 0.16.0",
  "toml",
  "uuid",
  "wait-timeout",
@@ -1439,6 +1439,12 @@ version = "0.15.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "949517c0cf1bf4ee812e2e07e08ab448e3ae0d23472aee8a06c985f0c8815b16"
 
+[[package]]
+name = "textwrap"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "222a222a5bfe1bba4a77b45ec488a741b3cb8872e5e499451fd7d0129c9c7c3d"
+
 [[package]]
 name = "thiserror"
 version = "1.0.36"
diff --git a/Cargo.toml b/Cargo.toml
index 3c7e88a..7846a74 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "nixpacks"
-version = "0.11.5"
+version = "0.11.6"
 edition = "2021"
 license = "MIT"
 authors = ["Railway <contact@railway.app>"]
@@ -43,7 +43,7 @@ wait-timeout = "0.2.0"
 base64 = "0.13.0"
 console = { default-features = false, version = "0.15.1" }
 box_drawing = "0.1.2"
-textwrap = { default-features = false, version = "0.15.0" }
+textwrap = { default-features = false, version = "0.16.0" }
 cargo_toml = "0.11.6"
 rand = "0.8.5"
 path-slash = "0.2.1"
diff --git a/examples/multiple-providers/nixpacks.toml b/examples/multiple-providers/nixpacks.toml
new file mode 100644
index 0000000..2cf6c3a
--- /dev/null
+++ b/examples/multiple-providers/nixpacks.toml
@@ -0,0 +1,4 @@
+providers = ["node", "python", "go", "deno"]
+
+[start]
+cmd = "node --version && python --version && go version && deno --version"
diff --git a/examples/node-python/nixpacks.toml b/examples/node-python/nixpacks.toml
new file mode 100644
index 0000000..29c9ddb
--- /dev/null
+++ b/examples/node-python/nixpacks.toml
@@ -0,0 +1,4 @@
+providers = ["...", "python"]
+
+[start]
+cmd = "./start.sh"
diff --git a/examples/node-python/package.json b/examples/node-python/package.json
new file mode 100644
index 0000000..9c4c0e9
--- /dev/null
+++ b/examples/node-python/package.json
@@ -0,0 +1,12 @@
+{
+  "name": "node-python",
+  "version": "1.0.0",
+  "description": "",
+  "main": "index.js",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "keywords": [],
+  "author": "",
+  "license": "ISC"
+}
diff --git a/examples/node-python/start.sh b/examples/node-python/start.sh
new file mode 100755
index 0000000..562e7c4
--- /dev/null
+++ b/examples/node-python/start.sh
@@ -0,0 +1,2 @@
+node --version
+cargo --version
diff --git a/src/main.rs b/src/main.rs
index c19bde7..faa8e0e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -270,8 +270,8 @@ fn main() -> Result<()> {
             let plan = generate_build_plan(path, envs, &options)?;
 
             let plan_s = match format {
-                PlanFormat::Json => serde_json::to_string_pretty(&plan)?,
-                PlanFormat::Toml => toml::to_string_pretty(&plan)?,
+                PlanFormat::Json => plan.to_json()?,
+                PlanFormat::Toml => plan.to_toml()?,
             };
 
             println!("{}", plan_s);
diff --git a/src/nixpacks/app.rs b/src/nixpacks/app.rs
index 15e3b82..dc8bf0b 100644
--- a/src/nixpacks/app.rs
+++ b/src/nixpacks/app.rs
@@ -109,7 +109,11 @@ impl App {
     pub fn read_file(&self, name: &str) -> Result<String> {
         let data = fs::read_to_string(PathBuf::from_slash_lossy(
             self.source.join(name).as_os_str(),
-        ))?;
+        ))
+        .with_context(|| {
+            let relative_path = self.strip_source_path(Path::new(name)).unwrap();
+            format!("Error reading {}", relative_path.to_str().unwrap())
+        })?;
 
         Ok(data.replace("\r\n", "\n"))
     }
diff --git a/src/nixpacks/nix/mod.rs b/src/nixpacks/nix/mod.rs
index 1547021..376ff49 100644
--- a/src/nixpacks/nix/mod.rs
+++ b/src/nixpacks/nix/mod.rs
@@ -87,8 +87,15 @@ fn nix_expression_for_group(group: &NixGroup) -> String {
         .archive
         .clone()
         .unwrap_or_else(|| NIXPKGS_ARCHIVE.to_string());
-    let pkgs = group.pkgs.clone().join(" ");
-    let libs = group.libs.clone().join(" ");
+
+    let mut pkgs = group.pkgs.clone();
+    pkgs.sort();
+    let pkgs = pkgs.join(" ");
+
+    let mut libs = group.libs.clone();
+    libs.sort();
+    let libs = libs.join(" ");
+
     let overlays_string = group
         .overlays
         .iter()
diff --git a/src/nixpacks/plan/generator.rs b/src/nixpacks/plan/generator.rs
index ea30627..98edbe4 100644
--- a/src/nixpacks/plan/generator.rs
+++ b/src/nixpacks/plan/generator.rs
@@ -11,7 +11,10 @@ use crate::{
 use anyhow::{bail, Context, Ok, Result};
 use colored::Colorize;
 
-use super::merge::Mergeable;
+use super::{
+    merge::Mergeable,
+    utils::{fill_auto_in_vec, remove_autos_from_vec},
+};
 
 const NIXPACKS_METADATA: &str = "NIXPACKS_METADATA";
 
@@ -69,7 +72,7 @@ impl NixpacksBuildPlanGenerator<'_> {
         Ok(plan)
     }
 
-    fn get_auto_providers(&self, app: &App, env: &Environment) -> Result<Vec<String>> {
+    fn get_detected_providers(&self, app: &App, env: &Environment) -> Result<Vec<String>> {
         let mut providers = Vec::new();
 
         for provider in self.providers {
@@ -90,14 +93,20 @@ impl NixpacksBuildPlanGenerator<'_> {
         app: &App,
         env: &Environment,
     ) -> Result<BuildPlan> {
-        let provider_names = if let Some(provider_names) = provider_names {
-            provider_names
-        } else {
-            self.get_auto_providers(app, env)?
-        };
+        let detected_providers = self.get_detected_providers(app, env)?;
+        let provider_names = remove_autos_from_vec(
+            fill_auto_in_vec(
+                Some(detected_providers),
+                Some(provider_names.unwrap_or_else(|| vec!["...".to_string()])),
+            )
+            .unwrap_or_default(),
+        );
 
         if provider_names.len() > 1 {
-            bail!("Only a single provider is supported at this time");
+            println!(
+                "{}",
+                "\n Using multiple providers is experimental\n".bright_yellow()
+            );
         }
 
         let mut plan = BuildPlan::default();
@@ -119,9 +128,9 @@ impl NixpacksBuildPlanGenerator<'_> {
                         .join_as_comma_separated(provider.name().to_owned());
                     metadata.push(metadata_string);
 
-                    plan = BuildPlan::merge(&plan, &provider_plan);
+                    plan = BuildPlan::merge(&provider_plan, &plan);
                 }
-            } else {
+            } else if name != "..." && name != "@auto" {
                 bail!("Provider {} not found", name);
             }
 
diff --git a/src/nixpacks/plan/merge.rs b/src/nixpacks/plan/merge.rs
index 37ddee0..e2a757b 100644
--- a/src/nixpacks/plan/merge.rs
+++ b/src/nixpacks/plan/merge.rs
@@ -1,5 +1,6 @@
 use super::{
     phase::{Phase, StartPhase},
+    utils::fill_auto_in_vec,
     BuildPlan,
 };
 
@@ -100,51 +101,10 @@ impl Mergeable for StartPhase {
     }
 }
 
-/// Fills in the `"..."`'s or `"@auto"`'s in `replacer` with the values from the `original`
-///
-/// ```
-/// let arr = fill_auto_in_vec(
-///   Some(vec!["a", "b", "c"]),
-///   Some(vec!["x", "...", "z"])
-/// );
-/// assert_eq!(Some(vec!["x", "...", "a", "b", "c", "z"]), arr);
-/// ```
-fn fill_auto_in_vec(
-    original: Option<Vec<String>>,
-    replacer: Option<Vec<String>>,
-) -> Option<Vec<String>> {
-    if let Some(replacer) = replacer {
-        let original = original.unwrap_or_default();
-        let modified = replacer
-            .into_iter()
-            .flat_map(|x| {
-                let v = x.clone();
-                if v == *"@auto" || v == *"..." {
-                    let mut fill = vec![v];
-                    fill.append(&mut original.clone());
-                    fill
-                } else {
-                    vec![x]
-                }
-            })
-            .collect::<Vec<_>>();
-
-        Some(modified)
-    } else {
-        original
-    }
-}
-
 #[cfg(test)]
 mod test {
     use super::*;
 
-    fn vs(v: Vec<&str>) -> Vec<String> {
-        v.into_iter()
-            .map(std::string::ToString::to_string)
-            .collect()
-    }
-
     #[test]
     fn test_merge_plan() {
         let merged = BuildPlan::merge(
@@ -244,24 +204,4 @@ mod test {
             merged
         );
     }
-
-    #[test]
-    fn test_fill_auto_in_vec() {
-        assert_eq!(
-            vec!["x", "...", "z"],
-            fill_auto_in_vec(None, Some(vs(vec!["x", "...", "z"]))).unwrap()
-        );
-        assert_eq!(
-            vec!["a", "b", "c"],
-            fill_auto_in_vec(Some(vs(vec!["a", "b", "c"])), None).unwrap()
-        );
-        assert_eq!(
-            vec!["x", "...", "a", "b", "c", "z"],
-            fill_auto_in_vec(
-                Some(vs(vec!["a", "b", "c"])),
-                Some(vs(vec!["x", "...", "z"]))
-            )
-            .unwrap()
-        );
-    }
 }
diff --git a/src/nixpacks/plan/mod.rs b/src/nixpacks/plan/mod.rs
index 8591759..6985efa 100644
--- a/src/nixpacks/plan/mod.rs
+++ b/src/nixpacks/plan/mod.rs
@@ -18,6 +18,7 @@ pub mod merge;
 pub mod phase;
 pub mod pretty_print;
 mod topological_sort;
+mod utils;
 
 pub trait PlanGenerator {
     fn generate_plan(&mut self, app: &App, environment: &Environment) -> Result<BuildPlan>;
@@ -64,6 +65,18 @@ impl BuildPlan {
         Ok(plan)
     }
 
+    pub fn to_toml(&self) -> Result<String> {
+        let mut plan = self.clone();
+        plan.remove_phase_names();
+        Ok(toml::to_string_pretty(&plan)?)
+    }
+
+    pub fn to_json(&self) -> Result<String> {
+        let mut plan = self.clone();
+        plan.remove_phase_names();
+        Ok(serde_json::to_string_pretty(&plan)?)
+    }
+
     pub fn add_phase(&mut self, phase: Phase) {
         let phases = self.phases.get_or_insert(BTreeMap::default());
         phases.insert(phase.get_name(), phase);
@@ -182,6 +195,13 @@ impl BuildPlan {
         }
     }
 
+    pub fn remove_phase_names(&mut self) {
+        let phases = self.phases.get_or_insert(BTreeMap::default());
+        for (_, phase) in phases.iter_mut() {
+            phase.name = None;
+        }
+    }
+
     pub fn from_environment(env: &Environment) -> Self {
         let mut phases: Vec<Phase> = Vec::new();
 
diff --git a/src/nixpacks/plan/phase.rs b/src/nixpacks/plan/phase.rs
index 1b81112..d3dc93c 100644
--- a/src/nixpacks/plan/phase.rs
+++ b/src/nixpacks/plan/phase.rs
@@ -5,6 +5,8 @@ use crate::nixpacks::{
 use serde::{Deserialize, Serialize};
 use std::collections::BTreeMap;
 
+use super::utils::remove_autos_from_vec;
+
 pub type Phases = BTreeMap<String, Phase>;
 
 #[serde_with::skip_serializing_none]
@@ -240,14 +242,6 @@ fn pin_option_vec(vec: &Option<Vec<String>>) -> Option<Vec<String>> {
     }
 }
 
-/// Removes all the `"..."`'s or `"@auto"`'s from the `original`
-fn remove_autos_from_vec(original: Vec<String>) -> Vec<String> {
-    original
-        .into_iter()
-        .filter(|x| x != "@auto" && x != "...")
-        .collect::<Vec<_>>()
-}
-
 fn add_to_option_vec<T>(values: Option<Vec<T>>, v: T) -> Vec<T> {
     if let Some(mut values) = values {
         values.push(v);
@@ -264,30 +258,3 @@ fn add_multiple_to_option_vec<T: Clone>(values: Option<Vec<T>>, new_values: Vec<
         new_values
     }
 }
-
-#[cfg(test)]
-mod test {
-    use super::*;
-
-    fn vs(v: Vec<&str>) -> Vec<String> {
-        v.into_iter()
-            .map(std::string::ToString::to_string)
-            .collect()
-    }
-
-    #[test]
-    fn test_remove_autos_from_vec() {
-        assert_eq!(
-            vs(vec!["a", "b", "c"]),
-            remove_autos_from_vec(vs(vec!["a", "b", "c"]))
-        );
-        assert_eq!(
-            vs(vec!["a", "c"]),
-            remove_autos_from_vec(vs(vec!["a", "...", "c"]))
-        );
-        assert_eq!(
-            vs(vec!["a", "c"]),
-            remove_autos_from_vec(vs(vec!["@auto", "a", "...", "c", "@auto"]))
-        );
-    }
-}
diff --git a/src/nixpacks/plan/pretty_print.rs b/src/nixpacks/plan/pretty_print.rs
index 5b3645d..c1ba644 100644
--- a/src/nixpacks/plan/pretty_print.rs
+++ b/src/nixpacks/plan/pretty_print.rs
@@ -17,6 +17,7 @@ impl BuildPlan {
         let phase_contents = self
             .get_sorted_phases()?
             .iter()
+            .filter(|phase| phase.uses_nix() || phase.apt_pkgs.is_some() || phase.cmds.is_some())
             .map(|phase| (phase.get_name(), self.get_phase_content(phase).unwrap()))
             .collect::<Vec<_>>();
 
diff --git a/src/nixpacks/plan/utils.rs b/src/nixpacks/plan/utils.rs
new file mode 100644
index 0000000..04ca9cf
--- /dev/null
+++ b/src/nixpacks/plan/utils.rs
@@ -0,0 +1,89 @@
+/// Removes all the `"..."`'s or `"@auto"`'s from the `original`
+pub fn remove_autos_from_vec(original: Vec<String>) -> Vec<String> {
+    original
+        .into_iter()
+        .filter(|x| x != "@auto" && x != "...")
+        .collect::<Vec<_>>()
+}
+
+/// Fills in the `"..."`'s or `"@auto"`'s in `replacer` with the values from the `original`
+///
+/// ```
+/// let arr = fill_auto_in_vec(
+///   Some(vec!["a", "b", "c"]),
+///   Some(vec!["x", "...", "z"])
+/// );
+/// assert_eq!(Some(vec!["x", "...", "a", "b", "c", "z"]), arr);
+/// ```
+pub fn fill_auto_in_vec(
+    original: Option<Vec<String>>,
+    replacer: Option<Vec<String>>,
+) -> Option<Vec<String>> {
+    if let Some(replacer) = replacer {
+        let original = original.unwrap_or_default();
+        let modified = replacer
+            .into_iter()
+            .flat_map(|x| {
+                let v = x.clone();
+                if v == *"@auto" || v == *"..." {
+                    let mut fill = vec![v];
+                    fill.append(&mut original.clone());
+                    fill
+                } else {
+                    vec![x]
+                }
+            })
+            .collect::<Vec<_>>();
+
+        Some(modified)
+    } else {
+        original
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    fn vs(v: Vec<&str>) -> Vec<String> {
+        v.into_iter()
+            .map(std::string::ToString::to_string)
+            .collect()
+    }
+
+    #[test]
+    fn test_remove_autos_from_vec() {
+        assert_eq!(
+            vs(vec!["a", "b", "c"]),
+            remove_autos_from_vec(vs(vec!["a", "b", "c"]))
+        );
+        assert_eq!(
+            vs(vec!["a", "c"]),
+            remove_autos_from_vec(vs(vec!["a", "...", "c"]))
+        );
+        assert_eq!(
+            vs(vec!["a", "c"]),
+            remove_autos_from_vec(vs(vec!["@auto", "a", "...", "c", "@auto"]))
+        );
+    }
+
+    #[test]
+    fn test_fill_auto_in_vec() {
+        assert_eq!(
+            vec!["x", "...", "z"],
+            fill_auto_in_vec(None, Some(vs(vec!["x", "...", "z"]))).unwrap()
+        );
+        assert_eq!(
+            vec!["a", "b", "c"],
+            fill_auto_in_vec(Some(vs(vec!["a", "b", "c"])), None).unwrap()
+        );
+        assert_eq!(
+            vec!["x", "...", "a", "b", "c", "z"],
+            fill_auto_in_vec(
+                Some(vs(vec!["a", "b", "c"])),
+                Some(vs(vec!["x", "...", "z"]))
+            )
+            .unwrap()
+        );
+    }
+}
diff --git a/src/providers/go.rs b/src/providers/go.rs
index 1894e6a..25c80a9 100644
--- a/src/providers/go.rs
+++ b/src/providers/go.rs
@@ -20,7 +20,7 @@ const GO_BUILD_CACHE_DIR: &str = "/root/.cache/go-build";
 
 impl Provider for GolangProvider {
     fn name(&self) -> &str {
-        "golang"
+        "go"
     }
 
     fn detect(&self, app: &App, _env: &Environment) -> Result<bool> {
@@ -51,13 +51,18 @@ impl Provider for GolangProvider {
         build.depends_on_phase("setup");
         plan.add_phase(build);
 
-        let mut start = StartPhase::new(format!("./{}", BINARY_NAME));
-        let cgo = env.get_variable("CGO_ENABLED").unwrap_or("0");
-        // Only run in a new image if CGO_ENABLED=0 (default)
-        if cgo != "1" {
-            start.run_in_slim_image();
+        let has_go_files = app.has_match("**/*.go");
+
+        if has_go_files {
+            let mut start = StartPhase::new(format!("./{}", BINARY_NAME));
+            let cgo = env.get_variable("CGO_ENABLED").unwrap_or("0");
+
+            // Only run in a new image if CGO_ENABLED=0 (default)
+            if cgo != "1" {
+                start.run_in_slim_image();
+            }
+            plan.set_start_phase(start);
         }
-        plan.set_start_phase(start);
 
         plan.add_variables(EnvironmentVariables::from([(
             "CGO_ENABLED".to_string(),
diff --git a/src/providers/php/mod.rs b/src/providers/php/mod.rs
index a4c48c0..4399e78 100644
--- a/src/providers/php/mod.rs
+++ b/src/providers/php/mod.rs
@@ -81,9 +81,10 @@ impl PhpProvider {
         if app.includes_file("composer.json") {
             install.add_cmd("composer install".to_string());
         };
-
-        if let Some(node_install_cmd) = NodeProvider::get_install_command(app) {
-            install.add_cmd(node_install_cmd);
+        if app.includes_file("package.json") {
+            if let Some(install_cmd) = NodeProvider::get_install_command(app) {
+                install.add_cmd(install_cmd);
+            }
         }
 
         install
diff --git a/src/providers/rust.rs b/src/providers/rust.rs
index 4b9719c..d061637 100644
--- a/src/providers/rust.rs
+++ b/src/providers/rust.rs
@@ -81,6 +81,9 @@ impl RustProvider {
 
     fn get_build(app: &App, env: &Environment) -> Result<Phase> {
         let mut build = Phase::build(None);
+        if !app.includes_file("Cargo.toml") {
+            return Ok(build);
+        }
 
         build.add_cmd("mkdir -p bin");
         build.depends_on = Some(vec!["setup".to_string()]);
@@ -269,9 +272,9 @@ impl RustProvider {
             return Ok(Some(name));
         }
 
-        let manifest = RustProvider::parse_cargo_toml(app)?.context("Missing Cargo.toml")?;
-
-        if let Some(workspace) = manifest.workspace {
+        if let Some(workspace) =
+            RustProvider::parse_cargo_toml(app)?.and_then(|manifest| manifest.workspace)
+        {
             if let Some(binary) = RustProvider::find_binary_in_workspace(app, &workspace)? {
                 return Ok(Some(binary));
             }
diff --git a/src/providers/staticfile.rs b/src/providers/staticfile.rs
index 9bdf365..5632ee3 100644
--- a/src/providers/staticfile.rs
+++ b/src/providers/staticfile.rs
@@ -38,10 +38,8 @@ impl Provider for StaticfileProvider {
     }
 
     fn get_build_plan(&self, app: &App, env: &Environment) -> Result<Option<BuildPlan>> {
-        let setup = Phase::setup(Some(vec![Pkg::new("nginx")]));
-        let build = Phase::build(Some(
-            "mkdir /etc/nginx/ /var/log/nginx/ /var/cache/nginx/".to_string(),
-        ));
+        let mut setup = Phase::setup(Some(vec![Pkg::new("nginx")]));
+        setup.add_cmd("mkdir /etc/nginx/ /var/log/nginx/ /var/cache/nginx/");
 
         // shell command to edit 0.0.0.0:80 to $PORT
         let shell_cmd = "[[ -z \"${PORT}\" ]] && echo \"Environment variable PORT not found. Using PORT 80\" || sed -i \"s/0.0.0.0:80/$PORT/g\"";
@@ -53,7 +51,7 @@ impl Provider for StaticfileProvider {
 
         let static_assets = StaticfileProvider::get_static_assets(app, env)?;
 
-        let mut plan = BuildPlan::new(&vec![setup, build], Some(start));
+        let mut plan = BuildPlan::new(&vec![setup], Some(start));
         plan.add_static_assets(static_assets);
 
         Ok(Some(plan))


Test patch:
diff --git a/tests/docker_run_tests.rs b/tests/docker_run_tests.rs
index 21b1701..f459f38 100644
--- a/tests/docker_run_tests.rs
+++ b/tests/docker_run_tests.rs
@@ -915,3 +915,12 @@ fn test_cobol_no_index() {
 
     assert_eq!(run_image(&name, None), "Hello from cobol! cobol-no-index");
 }
+
+#[test]
+fn test_multiple_providers() {
+    let name = simple_build("./examples/multiple-providers");
+    let output = run_image(&name, None);
+    assert!(output.contains("Python"));
+    assert!(output.contains("go"));
+    assert!(output.contains("deno"));
+}
diff --git a/tests/snapshots/generate_plan_tests__go.snap b/tests/snapshots/generate_plan_tests__go.snap
index 0dbbd99..62607d4 100644
--- a/tests/snapshots/generate_plan_tests__go.snap
+++ b/tests/snapshots/generate_plan_tests__go.snap
@@ -7,7 +7,7 @@ expression: plan
   "buildImage": "[build_image]",
   "variables": {
     "CGO_ENABLED": "0",
-    "NIXPACKS_METADATA": "golang"
+    "NIXPACKS_METADATA": "go"
   },
   "phases": {
     "build": {
diff --git a/tests/snapshots/generate_plan_tests__go_cgo_enabled.snap b/tests/snapshots/generate_plan_tests__go_cgo_enabled.snap
index 85db751..2aca028 100644
--- a/tests/snapshots/generate_plan_tests__go_cgo_enabled.snap
+++ b/tests/snapshots/generate_plan_tests__go_cgo_enabled.snap
@@ -7,7 +7,7 @@ expression: plan
   "buildImage": "[build_image]",
   "variables": {
     "CGO_ENABLED": "1",
-    "NIXPACKS_METADATA": "golang"
+    "NIXPACKS_METADATA": "go"
   },
   "phases": {
     "build": {
diff --git a/tests/snapshots/generate_plan_tests__go_custom_version.snap b/tests/snapshots/generate_plan_tests__go_custom_version.snap
index 33c1738..6e1e25c 100644
--- a/tests/snapshots/generate_plan_tests__go_custom_version.snap
+++ b/tests/snapshots/generate_plan_tests__go_custom_version.snap
@@ -7,7 +7,7 @@ expression: plan
   "buildImage": "[build_image]",
   "variables": {
     "CGO_ENABLED": "0",
-    "NIXPACKS_METADATA": "golang"
+    "NIXPACKS_METADATA": "go"
   },
   "phases": {
     "build": {
diff --git a/tests/snapshots/generate_plan_tests__go_gin.snap b/tests/snapshots/generate_plan_tests__go_gin.snap
index 33c1738..6e1e25c 100644
--- a/tests/snapshots/generate_plan_tests__go_gin.snap
+++ b/tests/snapshots/generate_plan_tests__go_gin.snap
@@ -7,7 +7,7 @@ expression: plan
   "buildImage": "[build_image]",
   "variables": {
     "CGO_ENABLED": "0",
-    "NIXPACKS_METADATA": "golang"
+    "NIXPACKS_METADATA": "go"
   },
   "phases": {
     "build": {
diff --git a/tests/snapshots/generate_plan_tests__go_mod.snap b/tests/snapshots/generate_plan_tests__go_mod.snap
index 0d62cf5..b6d74f7 100644
--- a/tests/snapshots/generate_plan_tests__go_mod.snap
+++ b/tests/snapshots/generate_plan_tests__go_mod.snap
@@ -7,7 +7,7 @@ expression: plan
   "buildImage": "[build_image]",
   "variables": {
     "CGO_ENABLED": "0",
-    "NIXPACKS_METADATA": "golang"
+    "NIXPACKS_METADATA": "go"
   },
   "phases": {
     "build": {
diff --git a/tests/snapshots/generate_plan_tests__multiple_providers.snap b/tests/snapshots/generate_plan_tests__multiple_providers.snap
new file mode 100644
index 0000000..b239b89
--- /dev/null
+++ b/tests/snapshots/generate_plan_tests__multiple_providers.snap
@@ -0,0 +1,98 @@
+---
+source: tests/generate_plan_tests.rs
+expression: plan
+---
+{
+  "providers": [],
+  "buildImage": "[build_image]",
+  "variables": {
+    "CGO_ENABLED": "0",
+    "CI": "true",
+    "NIXPACKS_METADATA": "node,python,go,deno",
+    "NODE_ENV": "production",
+    "NPM_CONFIG_PRODUCTION": "false",
+    "PYTHONUNBUFFERED": "1"
+  },
+  "phases": {
+    "build": {
+      "name": "build",
+      "dependsOn": [
+        "install"
+      ],
+      "cacheDirectories": [
+        "node_modules/.cache"
+      ]
+    },
+    "deno:setup": {
+      "name": "deno:setup",
+      "nixPkgs": [
+        "deno"
+      ],
+      "nixOverlays": [],
+      "nixpkgsArchive": "[archive]"
+    },
+    "go:build": {
+      "name": "go:build",
+      "dependsOn": [
+        "go:install",
+        "go:setup"
+      ],
+      "cacheDirectories": [
+        "/root/.cache/go-build"
+      ]
+    },
+    "go:setup": {
+      "name": "go:setup",
+      "nixPkgs": [
+        "go"
+      ],
+      "nixOverlays": [],
+      "nixpkgsArchive": "[archive]"
+    },
+    "install": {
+      "name": "install",
+      "dependsOn": [
+        "setup"
+      ],
+      "cacheDirectories": [
+        "/root/.npm"
+      ],
+      "paths": [
+        "/app/node_modules/.bin"
+      ]
+    },
+    "python:install": {
+      "name": "python:install",
+      "dependsOn": [
+        "python:setup"
+      ]
+    },
+    "python:setup": {
+      "name": "python:setup",
+      "nixPkgs": [
+        "python38",
+        "gcc"
+      ],
+      "nixLibs": [
+        "zlib",
+        "stdenv.cc.cc.lib"
+      ],
+      "nixOverlays": [],
+      "nixpkgsArchive": "[archive]"
+    },
+    "setup": {
+      "name": "setup",
+      "nixPkgs": [
+        "nodejs-16_x",
+        "npm-8_x"
+      ],
+      "nixOverlays": [
+        "https://github.com/railwayapp/nix-npm-overlay/archive/main.tar.gz"
+      ],
+      "nixpkgsArchive": "[archive]"
+    }
+  },
+  "start": {
+    "cmd": "node --version && python --version && go version && deno --version"
+  }
+}
diff --git a/tests/snapshots/generate_plan_tests__node_python.snap b/tests/snapshots/generate_plan_tests__node_python.snap
new file mode 100644
index 0000000..350d5fb
--- /dev/null
+++ b/tests/snapshots/generate_plan_tests__node_python.snap
@@ -0,0 +1,74 @@
+---
+source: tests/generate_plan_tests.rs
+expression: plan
+---
+{
+  "providers": [],
+  "buildImage": "[build_image]",
+  "variables": {
+    "CI": "true",
+    "NIXPACKS_METADATA": "node,python",
+    "NODE_ENV": "production",
+    "NPM_CONFIG_PRODUCTION": "false",
+    "PYTHONUNBUFFERED": "1"
+  },
+  "phases": {
+    "build": {
+      "name": "build",
+      "dependsOn": [
+        "install"
+      ],
+      "cacheDirectories": [
+        "node_modules/.cache"
+      ]
+    },
+    "install": {
+      "name": "install",
+      "dependsOn": [
+        "setup"
+      ],
+      "cmds": [
+        "npm i"
+      ],
+      "cacheDirectories": [
+        "/root/.npm"
+      ],
+      "paths": [
+        "/app/node_modules/.bin"
+      ]
+    },
+    "python:install": {
+      "name": "python:install",
+      "dependsOn": [
+        "python:setup"
+      ]
+    },
+    "python:setup": {
+      "name": "python:setup",
+      "nixPkgs": [
+        "python38",
+        "gcc"
+      ],
+      "nixLibs": [
+        "zlib",
+        "stdenv.cc.cc.lib"
+      ],
+      "nixOverlays": [],
+      "nixpkgsArchive": "[archive]"
+    },
+    "setup": {
+      "name": "setup",
+      "nixPkgs": [
+        "nodejs-16_x",
+        "npm-8_x"
+      ],
+      "nixOverlays": [
+        "https://github.com/railwayapp/nix-npm-overlay/archive/main.tar.gz"
+      ],
+      "nixpkgsArchive": "[archive]"
+    }
+  },
+  "start": {
+    "cmd": "./start.sh"
+  }
+}
diff --git a/tests/snapshots/generate_plan_tests__staticfile.snap b/tests/snapshots/generate_plan_tests__staticfile.snap
index 6a04747..19a2bae 100644
--- a/tests/snapshots/generate_plan_tests__staticfile.snap
+++ b/tests/snapshots/generate_plan_tests__staticfile.snap
@@ -13,22 +13,16 @@ expression: plan
     "nginx.conf": "daemon off;\nerror_log /dev/stdout info;\nworker_processes  auto;\nevents {\n    worker_connections  1024;\n}\n\nhttp {\n    include /nix/store/*-user-environment/conf/mime.types;\n    access_log /dev/stdout;\n    default_type  application/octet-stream;\n    sendfile       on;\n    keepalive_timeout  60;\n    types_hash_max_size 4096;\n    server {\n        listen    0.0.0.0:80;\n        gzip  \t  on;\n        root\t  /app/site;\n        location / {\n            auth_basic\t\"Password Required\";\nauth_basic_user_file\t/assets/.htpasswd;\n            autoindex off;\n        }\n\n    }\n}\n"
   },
   "phases": {
-    "build": {
-      "name": "build",
-      "dependsOn": [
-        "install"
-      ],
-      "cmds": [
-        "mkdir /etc/nginx/ /var/log/nginx/ /var/cache/nginx/"
-      ]
-    },
     "setup": {
       "name": "setup",
       "nixPkgs": [
         "nginx"
       ],
       "nixOverlays": [],
-      "nixpkgsArchive": "[archive]"
+      "nixpkgsArchive": "[archive]",
+      "cmds": [
+        "mkdir /etc/nginx/ /var/log/nginx/ /var/cache/nginx/"
+      ]
     }
   },
   "start": {