Skip to content
18 changes: 2 additions & 16 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,26 +133,12 @@ pub async fn create_docker_image(

let phase_count = plan.phases.clone().map_or(0, |phases| phases.len());
if phase_count > 0 {
println!("{}", plan.get_build_string()?);

let start = plan.start_phase.clone().unwrap_or_default();
if start.cmd.is_none() && !build_options.no_error_without_start {
bail!("No start command could be found")
}
} else {
println!("\nNixpacks was unable to generate a build plan for this app.\nPlease check the documentation for supported languages: https://nixpacks.com");
println!("\nThe contents of the app directory are:\n");

for file in &app.paths {
let path = app.strip_source_path(file.as_path())?;
println!(
" {}{}",
path.display(),
if file.is_dir() { "/" } else { "" }
);
}

std::process::exit(1);
bail!("Failed to generate a build plan. Check the documentation for auto-build supported targets: https://docs.lttle.cloud/build/auto-build#supported-targets")
}

if build_options.out_dir.is_none() {
Expand All @@ -170,7 +156,7 @@ fn ensure_docker_exists() -> Result<()> {
let mut docker_build_cmd = Command::new("docker");

if docker_build_cmd.output().is_err() {
bail!("Please install Docker to build the app https://docs.docker.com/engine/install/");
bail!("Docker is required to build the app. Check the prerequisites here: https://docs.lttle.cloud/build/auto-build#requirements");
}

Ok(())
Expand Down
22 changes: 6 additions & 16 deletions src/nixpacks/builder/docker/docker_image_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,13 @@ use crate::nixpacks::{
use anyhow::{bail, Context, Ok, Result};
use std::{
fs::{self, remove_dir_all, File},
process::Command,
process::{Command, Stdio},
};
use tempdir::TempDir;
use uuid::Uuid;

/// Builds Docker images from options, logging to stdout if the build is successful.
pub struct DockerImageBuilder {
logger: Logger,
options: DockerBuilderOptions,
}

Expand Down Expand Up @@ -106,10 +105,6 @@ impl ImageBuilder for DockerImageBuilder {
bail!("Docker build failed")
}

self.logger.log_section("Successfully Built!");
println!("\nRun:");
println!(" docker run -it {name}");

if self.options.incremental_cache_image.is_some() {
incremental_cache.create_image(
&incremental_cache_dirs,
Expand All @@ -120,18 +115,15 @@ impl ImageBuilder for DockerImageBuilder {
if output.is_temp {
remove_dir_all(output.root)?;
}
} else {
println!("\nSaved output to:");
println!(" {}", output.root.to_str().unwrap());
}

Ok(())
}
}

impl DockerImageBuilder {
pub fn new(logger: Logger, options: DockerBuilderOptions) -> DockerImageBuilder {
DockerImageBuilder { logger, options }
pub fn new(_logger: Logger, options: DockerBuilderOptions) -> DockerImageBuilder {
DockerImageBuilder { options }
}

/// Generates the Docker command and arguments for building the project.
Expand Down Expand Up @@ -167,6 +159,8 @@ impl DockerImageBuilder {
}

if self.options.quiet {
docker_build_cmd.stdout(Stdio::null());
docker_build_cmd.stderr(Stdio::null());
docker_build_cmd.arg("--quiet");
}

Expand Down Expand Up @@ -232,11 +226,7 @@ impl DockerImageBuilder {

/// Copies project files to temporary output dir, if that option was used.
fn write_app(&self, app_src: &str, output: &OutputDir) -> Result<()> {
if output.is_temp {
files::recursive_copy_dir(app_src, &output.root)
} else {
Ok(())
}
files::recursive_copy_dir(app_src, &output.root)
}

/// Writes the generated Dockerfile to the output dir.
Expand Down
38 changes: 38 additions & 0 deletions src/nixpacks/plan/pretty_print.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,45 @@ const FIRST_COLUMN_MIN_WIDTH: usize = 10;
const MIN_BOX_WIDTH: usize = 20;
const MAX_BOX_WIDTH: usize = 80;

#[derive(Debug, Clone)]
pub struct PhaseInfoDesc {
pub phases: Vec<(String, Vec<String>)>,
pub start: String,
}

impl BuildPlan {
pub fn get_phase_info_desc(&self) -> Result<PhaseInfoDesc> {
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()
.split('\n')
.map(|s| s.to_string())
.collect::<Vec<String>>(),
)
})
.collect::<Vec<_>>();

let start_contents = self
.start_phase
.clone()
.unwrap_or_default()
.cmd
.unwrap_or_default();

let info = PhaseInfoDesc {
phases: phase_contents,
start: start_contents,
};

Ok(info)
}

/// The pretty-printed build plan, emitted by `nixpacks build`.
pub fn get_build_string(&self) -> Result<String> {
let title_str = format!(" Nixpacks v{NIX_PACKS_VERSION} ");
Expand Down
17 changes: 9 additions & 8 deletions src/providers/node/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const BUN_NIXPKGS_ARCHIVE: &str = "5a0711127cd8b916c3d3128f473388c8c79df0da";
// We need to use a specific commit hash for Node versions <16 since it is EOL in the latest Nix packages
const NODE_LT_16_ARCHIVE: &str = "bf744fe90419885eefced41b3e5ae442d732712d";

const DEFAULT_NODE_VERSION: u32 = 18;
const DEFAULT_NODE_VERSION: u32 = 22;
const AVAILABLE_NODE_VERSIONS: &[u32] = &[14, 16, 18, 20, 22, 23];

const YARN_CACHE_DIR: &str = "/usr/local/share/.cache/yarn/v6";
Expand Down Expand Up @@ -453,22 +453,23 @@ impl NodeProvider {
return None;
}

let mut install_cmd = "npm i".to_string();
let mut install_cmd = "npm i --include=dev".to_string();
let package_manager = NodeProvider::get_package_manager(app);
if package_manager == "pnpm" {
install_cmd = "pnpm i --frozen-lockfile".to_string();
install_cmd = "pnpm i --frozen-lockfile --prod=false".to_string();
} else if package_manager == "yarn" {
// TODO: When using Corepack and modern Yarn, we may not have a .yarnrc.yml - need to
// read the Yarn version from stdout after enabling Corepack.
if app.includes_file(".yarnrc.yml") {
install_cmd = "yarn install --check-cache".to_string();
install_cmd = "yarn install --check-cache --production=false".to_string();
} else {
install_cmd = "yarn install --frozen-lockfile".to_string();
install_cmd = "yarn install --frozen-lockfile --production=false".to_string();
}
} else if app.includes_file("package-lock.json") {
install_cmd = "npm ci".to_string();
} else if app.includes_file("bun.lockb") || app.includes_file("bun.lock") {
} else if package_manager == "bun" {
install_cmd = "bun i --no-save".to_string();
} else if app.includes_file("package-lock.json") {
// a lock-file might include some deps arch dependent. so we need to rebuild them.
install_cmd = "npm ci --ignore-scripts --no-audit --no-fund && npm rebuild --no-audit --no-fund".to_string();
}

Some(install_cmd)
Expand Down
50 changes: 46 additions & 4 deletions src/providers/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,21 @@ impl Provider for PythonProvider {

fn metadata(&self, app: &App, env: &Environment) -> Result<ProviderMetadata> {
let is_django = PythonProvider::is_django(app, env)?;
let is_flask = PythonProvider::is_flask(app)?;
let is_fasthtml = PythonProvider::is_fasthtml(app)?;
let is_using_postgres = PythonProvider::is_using_postgres(app, env)?;
let is_poetry = app.includes_file("poetry.lock");
let is_pdm = app.includes_file("pdm.lock");
let is_uv = app.includes_file("uv.lock");

Ok(ProviderMetadata::from(vec![
(is_django, "django"),
(is_flask, "flask"),
(is_fasthtml, "fasthtml"),
(is_using_postgres, "postgres"),
(is_poetry, "poetry"),
(is_pdm, "pdm"),
(is_uv, "uv"),
]))
}

Expand Down Expand Up @@ -383,20 +389,40 @@ impl PythonProvider {
}

fn start(&self, app: &App, env: &Environment) -> Result<Option<StartPhase>> {
// Priority 1: Django with gunicorn and migrations
if PythonProvider::is_django(app, env)? {
let app_name = PythonProvider::get_django_app_name(app, env)?;

return Ok(Some(StartPhase::new(format!(
"python manage.py migrate && gunicorn {app_name}"
"python manage.py migrate && gunicorn {app_name}.wsgi:application --bind 0.0.0.0:${{PORT:-8000}}"
))));
}

// the python package is extracted from pyproject.toml, but this can often not be the desired entrypoint
// for this reason we prefer main.py to the module heuristic used in the pyproject.toml logic
// Priority 2: FastHTML with uvicorn (only checks main.py specifically)
if PythonProvider::is_fasthtml(app)?
&& app.includes_file("main.py")
&& PythonProvider::uses_dep(app, "uvicorn")?
{
return Ok(Some(StartPhase::new(
"uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}".to_string()
)));
}

// Priority 3: Flask with gunicorn (only checks main.py specifically)
if PythonProvider::is_flask(app)?
&& app.includes_file("main.py")
&& PythonProvider::uses_dep(app, "gunicorn")?
{
return Ok(Some(StartPhase::new(
"gunicorn --bind 0.0.0.0:${PORT:-8000} main:app".to_string()
)));
}

// Priority 4: main.py specifically (preserves original behavior)
if app.includes_file("main.py") {
return Ok(Some(StartPhase::new("python main.py".to_string())));
}

// Priority 5: PyProject entry points (original priority preserved)
if app.includes_file("pyproject.toml") {
if let OkResult(meta) = PythonProvider::parse_pyproject(app) {
if let Some(entry_point) = meta.entry_point {
Expand All @@ -408,6 +434,14 @@ impl PythonProvider {
}
}

// Priority 6: Other common entry files (new fallback)
let other_files = ["app.py", "bot.py", "hello.py", "server.py"];
for file in other_files {
if app.includes_file(file) {
return Ok(Some(StartPhase::new(format!("python {file}"))));
}
}

Ok(None)
}

Expand All @@ -418,6 +452,14 @@ impl PythonProvider {
Ok(has_manage && imports_django)
}

fn is_fasthtml(app: &App) -> Result<bool> {
PythonProvider::uses_dep(app, "python-fasthtml")
}

fn is_flask(app: &App) -> Result<bool> {
PythonProvider::uses_dep(app, "flask")
}

fn is_using_postgres(app: &App, _env: &Environment) -> Result<bool> {
// Check for the engine database type in settings.py
let re = Regex::new(r"django.db.backends.postgresql").unwrap();
Expand Down