use std::{
collections::{BTreeMap, HashMap},
io::IsTerminal,
path::{Path, PathBuf},
time::{Duration, Instant},
};
use crate::{
controllers::{
config::{ServiceInstance, fetch_environment_config},
develop::{
CodeServiceConfig, ComposeServiceStatus, DEFAULT_PORT, DevSession, DockerComposeFile,
DockerComposeNetwork, DockerComposeNetworks, DockerComposeService, DockerComposeVolume,
HttpsConfig, HttpsDomainConfig, LocalDevConfig, LocalDevelopContext, NetworkMode,
PortType, ServiceDomainConfig, ServicePort, ServiceSummary, build_port_infos,
build_service_endpoints, build_slug_port_mapping, certs_exist,
check_docker_compose_installed, check_mkcert_installed, ensure_mkcert_ca,
generate_caddyfile, generate_certs, generate_port, generate_random_port,
get_compose_path as develop_get_compose_path, get_develop_dir, get_existing_certs,
is_port_443_available, is_project_proxy_on_443, override_railway_vars,
print_context_info, print_domain_info, resolve_path, slugify, volume_name,
},
project::{self, ensure_project_and_environment_exist},
variables::get_service_variables,
},
util::prompt::{prompt_multi_options, prompt_options, prompt_path_with_default, prompt_text},
};
use clap::Subcommand;
use super::*;
#[derive(Debug, Parser)]
#[clap(
after_help = "Examples:\n\n railway dev up --dry-run --no-tui\n railway dev start --no-https\n railway dev configure\n railway dev stop\n\nAliases:\n up: start\n down: stop\n configure: config\n clean: reset"
)]
pub struct Args {
#[clap(subcommand)]
command: Option<DevelopCommand>,
#[clap(short, long)]
verbose: bool,
}
#[derive(Debug, Subcommand)]
enum DevelopCommand {
#[clap(visible_alias = "start")]
Up(UpArgs),
#[clap(visible_alias = "stop")]
Down(DownArgs),
#[clap(visible_alias = "reset")]
Clean(CleanArgs),
#[clap(visible_alias = "config")]
Configure(ConfigureArgs),
}
#[derive(Debug, Parser)]
struct ConfigureArgs {
#[clap(long)]
service: Option<String>,
#[clap(long, num_args = 0..=1, default_missing_value = "")]
remove: Option<String>,
}
#[derive(Debug, Parser, Default)]
struct UpArgs {
#[clap(short, long)]
environment: Option<String>,
#[clap(short, long)]
output: Option<PathBuf>,
#[clap(long)]
dry_run: bool,
#[clap(long)]
no_https: bool,
#[clap(short, long)]
verbose: bool,
#[clap(long)]
no_tui: bool,
}
#[derive(Debug, Parser)]
struct DownArgs {
#[clap(short, long)]
output: Option<PathBuf>,
}
#[derive(Debug, Parser)]
struct CleanArgs {
#[clap(short, long)]
output: Option<PathBuf>,
}
pub async fn command(args: Args) -> Result<()> {
eprintln!(
"{}",
"Experimental feature. API may change without notice.".yellow()
);
match args.command {
Some(DevelopCommand::Up(up_args)) => up_command(up_args).await,
Some(DevelopCommand::Down(down_args)) => down_command(down_args).await,
Some(DevelopCommand::Clean(clean_args)) => clean_command(clean_args).await,
Some(DevelopCommand::Configure(cfg_args)) => configure_command(cfg_args).await,
None => {
up_command(UpArgs {
verbose: args.verbose,
..Default::default()
})
.await
}
}
}
async fn get_compose_path(output: &Option<PathBuf>) -> Result<PathBuf> {
if let Some(path) = output {
return Ok(path.clone());
}
let configs = Configs::new()?;
let linked_project = configs.get_linked_project().await?;
Ok(develop_get_compose_path(&linked_project.project))
}
fn docker_install_url() -> &'static str {
match std::env::consts::OS {
"macos" => "https://docs.docker.com/desktop/setup/install/mac-install",
"windows" => "https://docs.docker.com/desktop/setup/install/windows-install",
_ => "https://docs.docker.com/desktop/setup/install/linux",
}
}
fn require_docker_compose() {
if !check_docker_compose_installed() {
eprintln!();
eprintln!("{}", "Docker Compose not found.".yellow());
eprintln!("Install Docker:");
eprintln!(" {}", docker_install_url());
std::process::exit(1);
}
}
async fn down_command(args: DownArgs) -> Result<()> {
require_docker_compose();
let compose_path = get_compose_path(&args.output).await?;
if !compose_path.exists() {
println!("{}", "Services already stopped".green());
return Ok(());
}
println!("{}", "Stopping services...".cyan());
let exit_status = tokio::process::Command::new("docker")
.args(["compose", "-f", &*compose_path.to_string_lossy(), "down"])
.status()
.await?;
if let Some(code) = exit_status.code() {
if code != 0 {
bail!("docker compose down exited with code {}", code);
}
}
println!("{}", "Services stopped".green());
Ok(())
}
async fn clean_command(args: CleanArgs) -> Result<()> {
require_docker_compose();
let compose_path = get_compose_path(&args.output).await?;
if !compose_path.exists() {
println!("{}", "Nothing to clean".green());
return Ok(());
}
let confirmed = crate::util::prompt::prompt_confirm_with_default(
"Stop services and remove volume data?",
false,
)?;
if !confirmed {
return Ok(());
}
println!("{}", "Cleaning up services...".cyan());
let exit_status = tokio::process::Command::new("docker")
.args([
"compose",
"-f",
&*compose_path.to_string_lossy(),
"down",
"-v",
])
.status()
.await?;
if let Some(code) = exit_status.code() {
if code != 0 {
bail!("docker compose down exited with code {}", code);
}
}
if let Some(parent) = compose_path.parent() {
std::fs::remove_dir_all(parent)?;
}
println!("{}", "Services cleaned".green());
Ok(())
}
struct CodeServiceDisplay {
service_id: String,
name: String,
configured: bool,
}
impl std::fmt::Display for CodeServiceDisplay {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.configured {
write!(f, "{} (configured)", self.name)
} else {
write!(f, "{}", self.name)
}
}
}
async fn configure_command(args: ConfigureArgs) -> Result<()> {
let configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
let linked_project = configs.get_linked_project().await?;
ensure_project_and_environment_exist(&client, &configs, &linked_project).await?;
let project_data =
project::get_project(&client, &configs, linked_project.project.clone()).await?;
let service_names: HashMap<String, String> = project_data
.services
.edges
.iter()
.map(|e| (e.node.id.clone(), e.node.name.clone()))
.collect();
let project_id = linked_project.project.clone();
let environment_id = linked_project.environment_id()?.to_string();
let env_response = fetch_environment_config(&client, &configs, &environment_id, false).await?;
let config = env_response.config;
if config.services.is_empty() {
println!(
"{}",
"No services in this environment. Add services with 'railway add'.".yellow()
);
return Ok(());
}
let code_services: Vec<_> = config
.services
.iter()
.filter(|(_, svc)| svc.is_code_based())
.collect();
if code_services.is_empty() {
println!(
"{}",
"No code-based services found. This environment only has image-based services."
.yellow()
);
return Ok(());
}
let mut local_dev_config = LocalDevConfig::load(&project_id)?;
if let Some(ref remove_arg) = args.remove {
let service_to_remove = if !remove_arg.is_empty() {
code_services
.iter()
.find(|(id, _)| {
service_names
.get(*id)
.map(|n| n == remove_arg)
.unwrap_or(false)
})
.map(|(id, _)| (*id).clone())
} else {
let configured: Vec<_> = code_services
.iter()
.filter(|(id, _)| local_dev_config.services.contains_key(*id))
.map(|(id, _)| CodeServiceDisplay {
service_id: (*id).clone(),
name: service_names
.get(*id)
.cloned()
.unwrap_or_else(|| (*id).clone()),
configured: true,
})
.collect();
if configured.is_empty() {
println!("{}", "No configured services to remove".yellow());
return Ok(());
}
let selected = prompt_options("Select service to remove configuration:", configured)?;
Some(selected.service_id)
};
if let Some(service_id) = service_to_remove {
let name = service_names
.get(&service_id)
.cloned()
.unwrap_or_else(|| service_id.clone());
if local_dev_config.remove_service(&service_id).is_some() {
local_dev_config.save(&project_id)?;
println!("{} Removed configuration for '{}'", "✓".green(), name);
} else {
println!(
"{}",
format!("Service '{}' is not configured", name).yellow()
);
}
}
return Ok(());
}
loop {
let service_id_to_configure = if let Some(ref name) = args.service {
code_services
.iter()
.find(|(id, _)| service_names.get(*id).map(|n| n == name).unwrap_or(false))
.map(|(id, _)| (*id).clone())
} else {
let options: Vec<_> = code_services
.iter()
.map(|(id, _)| CodeServiceDisplay {
service_id: (*id).clone(),
name: service_names
.get(*id)
.cloned()
.unwrap_or_else(|| (*id).clone()),
configured: local_dev_config.services.contains_key(*id),
})
.collect();
let selected = prompt_options("Select service to configure:", options)?;
Some(selected.service_id)
};
let Some(service_id) = service_id_to_configure else {
return Ok(());
};
let svc = config
.services
.get(&service_id)
.context("Service not found")?;
let name = service_names
.get(&service_id)
.cloned()
.unwrap_or_else(|| service_id.clone());
if local_dev_config.get_service(&service_id).is_none() {
let mut new_config = prompt_service_config(&name, svc, None)?;
if let Some(port) = new_config.port {
let conflicts: Vec<_> = local_dev_config
.services
.iter()
.filter(|(id, cfg)| *id != &service_id && cfg.port == Some(port))
.map(|(id, _)| service_names.get(id).cloned().unwrap_or_else(|| id.clone()))
.collect();
if !conflicts.is_empty() {
println!(
"\n{} Port {} is already used by: {}",
"Warning:".yellow().bold(),
port,
conflicts.join(", ")
);
let suggested = generate_random_port();
let port_input =
prompt_text(&format!("Choose a different port [{}]:", suggested))?;
new_config.port = Some(if port_input.is_empty() {
suggested
} else {
port_input.parse().context("Invalid port number")?
});
}
}
local_dev_config.set_service(service_id.clone(), new_config);
local_dev_config.save(&project_id)?;
println!("{} Configured '{}'", "✓".green(), name);
}
loop {
let action = show_service_config_menu(
&name,
local_dev_config.get_service(&service_id).unwrap(),
)?;
match action {
ConfigAction::ChangeCommand => {
let existing = local_dev_config.get_service(&service_id).unwrap();
let new_command = prompt_text(&format!(
"Dev command for '{}' [{}]:",
name, existing.command
))
.map(|s| {
if s.is_empty() {
existing.command.clone()
} else {
s
}
})?;
let mut updated = existing.clone();
updated.command = new_command;
local_dev_config.set_service(service_id.clone(), updated);
local_dev_config.save(&project_id)?;
println!("{} Updated command for '{}'", "✓".green(), name);
}
ConfigAction::ChangeDirectory => {
let existing = local_dev_config.get_service(&service_id).unwrap();
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let default_dir = PathBuf::from(&existing.directory)
.strip_prefix(&cwd)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| existing.directory.clone());
let input_path = prompt_path_with_default(
&format!("Directory for '{}' (relative to cwd):", name),
&default_dir,
)?;
let directory = if input_path.is_absolute() {
input_path.to_string_lossy().to_string()
} else {
resolve_path(cwd.join(&input_path))
.to_string_lossy()
.to_string()
};
let mut updated = existing.clone();
updated.directory = directory;
local_dev_config.set_service(service_id.clone(), updated);
local_dev_config.save(&project_id)?;
println!("{} Updated directory for '{}'", "✓".green(), name);
}
ConfigAction::ChangePort => {
let existing = local_dev_config.get_service(&service_id).unwrap();
let railway_port = svc.get_ports().first().map(|&p| p as u16);
let current_port = existing.port.or(railway_port).unwrap_or(DEFAULT_PORT);
let port_input =
prompt_text(&format!("Port for '{}' [{}]:", name, current_port))?;
let mut new_port = if port_input.is_empty() {
current_port
} else {
port_input.parse().context("Invalid port number")?
};
let conflicts: Vec<_> = local_dev_config
.services
.iter()
.filter(|(id, cfg)| *id != &service_id && cfg.port == Some(new_port))
.map(|(id, _)| service_names.get(id).cloned().unwrap_or_else(|| id.clone()))
.collect();
if !conflicts.is_empty() {
println!(
"\n{} Port {} is already used by: {}",
"Warning:".yellow().bold(),
new_port,
conflicts.join(", ")
);
let suggested = generate_random_port();
let port_input =
prompt_text(&format!("Choose a different port [{}]:", suggested))?;
new_port = if port_input.is_empty() {
suggested
} else {
port_input.parse().context("Invalid port number")?
};
}
let mut updated = existing.clone();
updated.port = Some(new_port);
local_dev_config.set_service(service_id.clone(), updated);
local_dev_config.save(&project_id)?;
println!("{} Updated port for '{}'", "✓".green(), name);
}
ConfigAction::Remove => {
local_dev_config.remove_service(&service_id);
local_dev_config.save(&project_id)?;
println!("{} Removed configuration for '{}'", "✓".green(), name);
break; }
ConfigAction::Back => {
break; }
}
}
if args.service.is_some() {
return Ok(());
}
}
}
#[derive(Debug, Clone, Copy)]
enum ConfigAction {
ChangeCommand,
ChangeDirectory,
ChangePort,
Remove,
Back,
}
impl std::fmt::Display for ConfigAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigAction::ChangeCommand => write!(f, "Change command"),
ConfigAction::ChangeDirectory => write!(f, "Change directory"),
ConfigAction::ChangePort => write!(f, "Change port"),
ConfigAction::Remove => write!(f, "Remove configuration"),
ConfigAction::Back => write!(f, "← Configure another service"),
}
}
}
fn show_service_config_menu(name: &str, config: &CodeServiceConfig) -> Result<ConfigAction> {
let display_dir = match std::env::current_dir() {
Ok(cwd) => PathBuf::from(&config.directory)
.strip_prefix(&cwd)
.map(|p| format!("./{}", p.display()))
.unwrap_or_else(|_| config.directory.clone()),
Err(_) => config.directory.clone(),
};
println!("\n{}", format!("Service '{}'", name).cyan().bold());
println!(" {}: {}", "command".dimmed(), config.command);
println!(" {}: {}", "directory".dimmed(), display_dir);
if let Some(port) = config.port {
println!(" {}: {}", "port".dimmed(), port);
}
println!();
let options = vec![
ConfigAction::ChangeCommand,
ConfigAction::ChangeDirectory,
ConfigAction::ChangePort,
ConfigAction::Remove,
ConfigAction::Back,
];
prompt_options("", options)
}
fn prompt_service_config(
name: &str,
svc: &ServiceInstance,
existing: Option<&CodeServiceConfig>,
) -> Result<CodeServiceConfig> {
println!("\n{}", format!("Configure '{}'", name).cyan().bold());
let default_command = existing.map(|e| e.command.as_str()).unwrap_or("");
let command = if default_command.is_empty() {
prompt_text(&format!("Dev command for '{}':", name))?
} else {
prompt_text(&format!(
"Dev command for '{}' [{}]:",
name, default_command
))
.map(|s| {
if s.is_empty() {
default_command.to_string()
} else {
s
}
})?
};
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let default_dir = existing
.map(|e| {
PathBuf::from(&e.directory)
.strip_prefix(&cwd)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| e.directory.clone())
})
.unwrap_or_else(|| ".".to_string());
let input_path = prompt_path_with_default(
&format!("Directory for '{}' (relative to current directory):", name),
&default_dir,
)?;
let directory = if input_path.is_absolute() {
input_path.to_string_lossy().to_string()
} else {
resolve_path(cwd.join(&input_path))
.to_string_lossy()
.to_string()
};
let inferred_port = svc.get_ports().first().map(|&p| p as u16);
let default_port = existing.and_then(|e| e.port).or(inferred_port);
let port = if let Some(default) = default_port {
let port_input = prompt_text(&format!("Port for '{}' [{}]:", name, default))?;
if port_input.is_empty() {
Some(default)
} else {
Some(port_input.parse().context("Invalid port number")?)
}
} else {
None
};
Ok(CodeServiceConfig {
command,
directory,
port,
})
}
fn prompt_initial_service_setup(
code_services: &[(&String, &ServiceInstance)],
service_names: &HashMap<String, String>,
config: &crate::controllers::config::EnvironmentConfig,
local_dev_config: &mut LocalDevConfig,
) -> Result<()> {
println!("\n{}", "Configure local code services".cyan().bold());
println!("{}", "(Press space to select, enter to confirm)".dimmed());
let options: Vec<_> = code_services
.iter()
.map(|(id, _)| CodeServiceDisplay {
service_id: (*id).clone(),
name: service_names
.get(*id)
.cloned()
.unwrap_or_else(|| (*id).clone()),
configured: false,
})
.collect();
let selected = prompt_multi_options("Select services to configure:", options)?;
for service_display in &selected {
let svc = config
.services
.get(&service_display.service_id)
.context("Service not found")?;
let name = &service_display.name;
let mut new_config = prompt_service_config(name, svc, None)?;
if let Some(port) = new_config.port {
let conflicts: Vec<_> = local_dev_config
.services
.iter()
.filter(|(id, cfg)| *id != &service_display.service_id && cfg.port == Some(port))
.map(|(id, _)| service_names.get(id).cloned().unwrap_or_else(|| id.clone()))
.collect();
if !conflicts.is_empty() {
println!(
"\n{} Port {} is already used by: {}",
"Warning:".yellow().bold(),
port,
conflicts.join(", ")
);
let suggested = generate_random_port();
let port_input = prompt_text(&format!("Choose a different port [{}]:", suggested))?;
new_config.port = Some(if port_input.is_empty() {
suggested
} else {
port_input.parse().context("Invalid port number")?
});
}
}
local_dev_config.set_service(service_display.service_id.clone(), new_config);
}
if !selected.is_empty() {
println!("\n{}", "Configured services:".green().bold());
for service_display in &selected {
if let Some(cfg) = local_dev_config.get_service(&service_display.service_id) {
let port_str = cfg
.port
.map(|p| p.to_string())
.unwrap_or_else(|| "-".to_string());
println!(
" {} {} (port {})",
"•".dimmed(),
service_display.name.cyan(),
port_str
);
}
}
}
Ok(())
}
fn detect_port_conflicts(
configs: &HashMap<String, CodeServiceConfig>,
service_names: &HashMap<String, String>,
) -> Vec<(u16, Vec<String>)> {
let mut port_to_services: HashMap<u16, Vec<String>> = HashMap::new();
for (service_id, config) in configs {
if let Some(port) = config.port {
let name = service_names
.get(service_id)
.cloned()
.unwrap_or_else(|| service_id.clone());
port_to_services.entry(port).or_default().push(name);
}
}
port_to_services
.into_iter()
.filter(|(_, services)| services.len() > 1)
.collect()
}
fn resolve_port_conflicts(
local_dev_config: &mut LocalDevConfig,
service_names: &HashMap<String, String>,
project_id: &str,
) -> Result<bool> {
let conflicts = detect_port_conflicts(&local_dev_config.services, service_names);
if conflicts.is_empty() {
return Ok(false);
}
if !std::io::stdout().is_terminal() {
for (port, services) in &conflicts {
eprintln!(
"{} Port {} is used by multiple services: {}",
"Error:".red().bold(),
port,
services.join(", ")
);
}
anyhow::bail!("Port conflicts detected. Run 'railway develop configure' to resolve.");
}
println!("\n{} Port conflicts detected:", "Warning:".yellow().bold());
for (port, services) in &conflicts {
println!(" Port {}: {}", port, services.join(", "));
}
println!();
for (port, conflicting_services) in conflicts {
for service_name in conflicting_services.iter().skip(1) {
let service_id = service_names
.iter()
.find(|(_, name)| *name == service_name)
.map(|(id, _)| id.clone());
if let Some(service_id) = service_id {
let suggested = generate_random_port();
let port_input = prompt_text(&format!(
"New port for '{}' (currently {}) [{}]:",
service_name, port, suggested
))?;
let new_port = if port_input.is_empty() {
suggested
} else {
port_input.parse().context("Invalid port number")?
};
if let Some(mut cfg) = local_dev_config.get_service(&service_id).cloned() {
cfg.port = Some(new_port);
local_dev_config.set_service(service_id, cfg);
}
}
}
}
local_dev_config.save(project_id)?;
println!();
Ok(true)
}
async fn up_command(args: UpArgs) -> Result<()> {
require_docker_compose();
let configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
let linked_project = configs.get_linked_project().await?;
ensure_project_and_environment_exist(&client, &configs, &linked_project).await?;
let project_data =
project::get_project(&client, &configs, linked_project.project.clone()).await?;
let service_names: HashMap<String, String> = project_data
.services
.edges
.iter()
.map(|e| (e.node.id.clone(), e.node.name.clone()))
.collect();
let project_id = linked_project.project.clone();
let environment_id = match args.environment.clone() {
Some(env) => env,
None => linked_project.environment_id()?.to_string(),
};
let env_response = fetch_environment_config(&client, &configs, &environment_id, true).await?;
let env_name = env_response.name;
let config = env_response.config;
let service_slugs = build_service_endpoints(&service_names, &config);
let image_services: Vec<_> = config
.services
.iter()
.filter(|(_, svc)| svc.is_image_based())
.collect();
let code_services: Vec<_> = config
.services
.iter()
.filter(|(_, svc)| svc.is_code_based())
.collect();
let mut local_dev_config = LocalDevConfig::load(&project_id)?;
let config_file_exists = LocalDevConfig::path(&project_id).exists();
if !config_file_exists && !code_services.is_empty() && std::io::stdout().is_terminal() {
prompt_initial_service_setup(
&code_services,
&service_names,
&config,
&mut local_dev_config,
)?;
local_dev_config.save(&project_id)?;
println!();
}
let configured_code_services: Vec<_> = code_services
.iter()
.filter(|(id, _)| local_dev_config.services.contains_key(*id))
.collect();
resolve_port_conflicts(&mut local_dev_config, &service_names, &project_id)?;
if image_services.is_empty() && configured_code_services.is_empty() {
if config.services.is_empty() {
println!();
println!("No services in environment {}", env_name.blue().bold());
println!("Add services with {}", "railway add".cyan());
} else {
println!();
println!(
"No services to run in environment {}",
env_name.blue().bold()
);
println!(
"Use {} to set up code services",
"railway develop configure".cyan()
);
}
println!();
return Ok(());
}
let https_config = if args.no_https {
None
} else {
setup_https(&project_data.name, &project_id)?
};
let mut ctx = LocalDevelopContext::new(NetworkMode::Docker);
ctx.https_config = https_config.as_ref().map(|c| HttpsDomainConfig {
base_domain: c.base_domain.clone(),
use_port_443: c.use_port_443,
});
for (service_id, svc) in &image_services {
let slug = service_slugs.get(*service_id).cloned().unwrap_or_default();
let port_mapping = build_slug_port_mapping(service_id, svc);
ctx.services.insert(
(*service_id).clone(),
ServiceDomainConfig {
slug,
port_mapping,
public_domain_prod: None,
https_proxy_port: None, },
);
}
for (service_id, svc) in &configured_code_services {
let slug = service_slugs
.get(*service_id)
.cloned()
.unwrap_or_else(|| slugify(service_id));
if let Some(dev_config) = local_dev_config.get_service(service_id) {
let internal_port = dev_config
.port
.map(|p| p as i64)
.or_else(|| svc.get_ports().first().copied())
.unwrap_or(DEFAULT_PORT as i64);
let mut port_mapping = HashMap::new();
for port in svc.get_ports() {
port_mapping.insert(port, internal_port as u16);
}
port_mapping.insert(internal_port, internal_port as u16);
let https_proxy_port = Some(generate_port(service_id, internal_port));
ctx.services.insert(
(*service_id).clone(),
ServiceDomainConfig {
slug,
port_mapping,
public_domain_prod: None,
https_proxy_port,
},
);
}
}
let variable_futures: Vec<_> = image_services
.iter()
.map(|(service_id, _)| {
get_service_variables(
&client,
&configs,
linked_project.project.clone(),
environment_id.clone(),
(*service_id).clone(),
)
})
.collect();
let variable_results = futures::future::join_all(variable_futures).await;
let resolved_vars: HashMap<String, BTreeMap<String, String>> = image_services
.iter()
.zip(variable_results.into_iter())
.filter_map(|((service_id, _), result)| {
result.ok().map(|vars| ((*service_id).clone(), vars))
})
.collect();
for (service_id, vars) in &resolved_vars {
if let Some(prod_domain) = vars.get("RAILWAY_PUBLIC_DOMAIN") {
if let Some(config) = ctx.services.get_mut(service_id) {
config.public_domain_prod = Some(prod_domain.clone());
}
}
}
let compose_result = build_image_service_compose(
&image_services,
&service_names,
&resolved_vars,
&environment_id,
&ctx,
);
let mut compose_services = compose_result.services;
let compose_volumes = compose_result.volumes;
let service_summaries = compose_result.summaries;
let service_count = compose_services.len();
if args.verbose {
for (service_id, _) in &image_services {
let name = service_names
.get(*service_id)
.cloned()
.unwrap_or_else(|| (*service_id).clone());
if let Some(domains) = ctx.for_service(service_id) {
print_domain_info(&name, &domains);
}
}
print_context_info(&ctx);
}
if let Some(ref config) = https_config {
setup_caddy_proxy(
&mut compose_services,
&service_summaries,
&configured_code_services,
&local_dev_config,
&service_slugs,
config,
&project_id,
)?;
}
let compose = DockerComposeFile {
services: compose_services,
networks: Some(DockerComposeNetworks {
railway: DockerComposeNetwork {
driver: "bridge".to_string(),
},
}),
volumes: compose_volumes,
};
let output_path = args
.output
.unwrap_or_else(|| get_develop_dir(&project_id).join("docker-compose.yml"));
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent)?;
}
let yaml = serde_yaml::to_string(&compose)?;
let tmp_path = output_path.with_extension("yml.tmp");
std::fs::write(&tmp_path, &yaml)?;
std::fs::rename(&tmp_path, &output_path)?;
if args.dry_run {
println!("\n{} {}", "Generated".green(), output_path.display());
println!("\n{}", "Dry run mode, not starting services".yellow());
println!("\nTo start manually:");
println!(" docker compose -f {} up", output_path.display());
return Ok(());
}
if !image_services.is_empty() {
println!("{}", "Starting image services...".cyan());
let output_path_str = output_path.to_string_lossy();
let exit_status = tokio::process::Command::new("docker")
.args(["compose", "-f", &*output_path_str, "up", "-d"])
.status()
.await?;
if let Some(code) = exit_status.code() {
if code != 0 {
bail!("docker compose exited with code {}", code);
}
}
if !configured_code_services.is_empty() {
println!("\n{}", "Waiting for services to be ready...".dimmed());
wait_for_services(&output_path, Duration::from_secs(60)).await?;
}
}
if !service_summaries.is_empty() {
let svc_word = if service_count == 1 {
"service"
} else {
"services"
};
println!(
" {} Started {} image {}",
"✓".green(),
service_count,
svc_word
);
println!();
let use_tui =
!args.no_tui && std::io::stdout().is_terminal() && !configured_code_services.is_empty();
if !use_tui {
for summary in &service_summaries {
print_image_service_summary(summary, &https_config);
}
}
}
if configured_code_services.is_empty() {
print_next_steps(&code_services, &service_names);
return Ok(());
}
let code_var_futures: Vec<_> = configured_code_services
.iter()
.map(|(service_id, _)| {
get_service_variables(
&client,
&configs,
linked_project.project.clone(),
environment_id.clone(),
(*service_id).clone(),
)
})
.collect();
let code_var_results = futures::future::join_all(code_var_futures).await;
let code_resolved_vars: HashMap<String, BTreeMap<String, String>> = configured_code_services
.iter()
.zip(code_var_results.into_iter())
.filter_map(|((service_id, _), result)| {
result.ok().map(|vars| ((*service_id).clone(), vars))
})
.collect();
for (service_id, vars) in &code_resolved_vars {
if let Some(prod_domain) = vars.get("RAILWAY_PUBLIC_DOMAIN") {
if let Some(config) = ctx.services.get_mut(service_id) {
config.public_domain_prod = Some(prod_domain.clone());
}
}
}
ctx.mode = NetworkMode::Host;
let use_tui = !args.no_tui && std::io::stdout().is_terminal();
let mut session = DevSession::start(
&project_id,
&configured_code_services,
&service_names,
&local_dev_config,
&code_resolved_vars,
&ctx,
&https_config,
&service_summaries,
output_path,
!image_services.is_empty(),
use_tui,
args.verbose,
)
.await?;
session.run(use_tui).await?;
session.shutdown().await;
Ok(())
}
fn print_next_steps(
unconfigured_code_services: &[(&String, &ServiceInstance)],
service_names: &HashMap<String, String>,
) {
println!("{}", "Next steps".cyan().bold());
println!();
println!(
" {} Run a command with access to these services:",
"•".dimmed()
);
println!(" {}", "railway run <command>".cyan());
println!();
if !unconfigured_code_services.is_empty() {
println!(" {} Configure code services to run locally:", "•".dimmed());
println!(" {}", "railway dev configure".cyan());
println!();
println!(" {}", "Available:".dimmed());
for (id, _) in unconfigured_code_services {
if let Some(name) = service_names.get(*id) {
println!(" {} {}", "·".dimmed(), name);
}
}
println!();
}
}
fn print_image_service_summary(summary: &ServiceSummary, https_config: &Option<HttpsConfig>) {
println!("{}", summary.name.green().bold());
println!(" {}: {}", "Image".dimmed(), summary.image);
println!(
" {}: {} variables",
"Variables".dimmed(),
summary.var_count
);
if !summary.ports.is_empty() {
println!(" {}:", "Networking".dimmed());
let slug = slugify(&summary.name);
for p in &summary.ports {
match (https_config, &p.port_type) {
(Some(config), PortType::Http) => {
println!(
" {}: http://localhost:{}",
"Private".dimmed(),
p.external
);
if config.use_port_443 {
println!(
" {}: https://{}.{}",
"Public".dimmed(),
slug,
config.base_domain
);
} else {
println!(
" {}: https://{}:{}",
"Public".dimmed(),
config.base_domain,
p.public_port
);
}
}
(_, PortType::Tcp) => {
println!(" {}: localhost:{}", "TCP".dimmed(), p.external);
}
(None, PortType::Http) => {
println!(" http://localhost:{}", p.external);
}
}
}
}
if !summary.volumes.is_empty() {
let label = if summary.volumes.len() == 1 {
"Volume"
} else {
"Volumes"
};
println!(" {}: {}", label.dimmed(), summary.volumes.join(", "));
}
println!();
}
struct ImageServiceComposeResult {
services: BTreeMap<String, DockerComposeService>,
volumes: BTreeMap<String, DockerComposeVolume>,
summaries: Vec<ServiceSummary>,
}
fn build_image_service_compose(
image_services: &[(&String, &ServiceInstance)],
service_names: &HashMap<String, String>,
resolved_vars: &HashMap<String, BTreeMap<String, String>>,
environment_id: &str,
ctx: &LocalDevelopContext,
) -> ImageServiceComposeResult {
let mut compose_services = BTreeMap::new();
let mut compose_volumes = BTreeMap::new();
let mut service_summaries = Vec::new();
for (service_id, svc) in image_services {
let service_name = service_names
.get(*service_id)
.cloned()
.unwrap_or_else(|| (*service_id).clone());
let slug = slugify(&service_name);
let image = svc.source.as_ref().unwrap().image.clone().unwrap();
let port_infos = build_port_infos(service_id, svc);
let raw_vars = resolved_vars.get(*service_id).cloned().unwrap_or_default();
let service_domains = ctx
.for_service(service_id)
.expect("image services added to ctx before calling this fn");
let environment = override_railway_vars(raw_vars, Some(&service_domains), ctx);
let ports: Vec<String> = port_infos
.iter()
.map(|p| format!("{}:{}", p.external, p.internal))
.collect();
let mut service_volumes = Vec::new();
for (vol_id, vol_mount) in &svc.volume_mounts {
if let Some(mount_path) = &vol_mount.mount_path {
let vol_name = volume_name(environment_id, vol_id);
service_volumes.push(format!("{}:{}", vol_name, mount_path));
compose_volumes.insert(vol_name, DockerComposeVolume {});
}
}
let start_command = svc
.deploy
.as_ref()
.and_then(|d| d.start_command.clone())
.map(|cmd| cmd.replace('$', "$$"));
let volume_paths: Vec<String> = svc
.volume_mounts
.values()
.filter_map(|v| v.mount_path.clone())
.collect();
service_summaries.push(ServiceSummary {
name: service_name,
image: image.clone(),
var_count: environment.len(),
ports: port_infos,
volumes: volume_paths,
});
compose_services.insert(
slug,
DockerComposeService {
image,
command: start_command,
restart: Some("on-failure".to_string()),
environment,
ports,
volumes: service_volumes,
networks: vec!["railway".to_string()],
extra_hosts: Vec::new(),
},
);
}
ImageServiceComposeResult {
services: compose_services,
volumes: compose_volumes,
summaries: service_summaries,
}
}
fn setup_caddy_proxy(
compose_services: &mut BTreeMap<String, DockerComposeService>,
service_summaries: &[ServiceSummary],
configured_code_services: &[&(&String, &ServiceInstance)],
local_dev_config: &LocalDevConfig,
service_slugs: &HashMap<String, String>,
https_config: &HttpsConfig,
project_id: &str,
) -> Result<()> {
let mut service_ports: Vec<ServicePort> = service_summaries
.iter()
.flat_map(|s| {
s.ports.iter().map(|p| ServicePort {
slug: slugify(&s.name),
internal_port: p.internal,
external_port: p.public_port,
is_http: matches!(p.port_type, PortType::Http),
is_code_service: false,
})
})
.collect();
for &(service_id, svc) in configured_code_services {
if let Some(dev_config) = local_dev_config.get_service(service_id) {
let slug = service_slugs
.get(*service_id)
.cloned()
.unwrap_or_else(|| slugify(service_id));
let internal_port = dev_config
.port
.map(|p| p as i64)
.or_else(|| svc.get_ports().first().copied())
.unwrap_or(DEFAULT_PORT as i64);
let proxy_port = generate_port(service_id, internal_port);
service_ports.push(ServicePort {
slug,
internal_port,
external_port: proxy_port,
is_http: true,
is_code_service: true,
});
}
}
let proxy_ports: Vec<String> = if https_config.use_port_443 {
vec!["443:443".to_string()]
} else {
service_ports
.iter()
.filter(|p| p.is_http)
.map(|p| format!("{}:{}", p.external_port, p.external_port))
.collect()
};
if !proxy_ports.is_empty() {
compose_services.insert(
"railway-proxy".to_string(),
DockerComposeService {
image: "caddy:2-alpine".to_string(),
command: None,
restart: Some("on-failure".to_string()),
environment: BTreeMap::new(),
ports: proxy_ports,
volumes: vec![
"./Caddyfile:/etc/caddy/Caddyfile:ro".to_string(),
"./certs:/certs:ro".to_string(),
],
networks: vec!["railway".to_string()],
extra_hosts: vec!["host.docker.internal:host-gateway".to_string()],
},
);
}
let develop_dir = get_develop_dir(project_id);
std::fs::create_dir_all(&develop_dir)?;
let caddyfile = generate_caddyfile(&service_ports, https_config);
std::fs::write(develop_dir.join("Caddyfile"), caddyfile)?;
std::fs::write(develop_dir.join("https_domain"), &https_config.base_domain)?;
Ok(())
}
async fn wait_for_services(compose_path: &Path, timeout: Duration) -> Result<()> {
let start = Instant::now();
loop {
if start.elapsed() > timeout {
bail!("Timeout waiting for services to be ready");
}
let output = tokio::process::Command::new("docker")
.args([
"compose",
"-f",
&*compose_path.to_string_lossy(),
"ps",
"--format",
"json",
])
.output()
.await?;
let services: Vec<ComposeServiceStatus> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect();
for s in &services {
if s.state == "exited" && s.exit_code != 0 {
bail!("Service '{}' exited with code {}", s.service, s.exit_code);
}
}
let all_ready = services.iter().all(|s| {
if !s.health.is_empty() {
s.health == "healthy"
} else {
s.state == "running" || (s.state == "exited" && s.exit_code == 0)
}
});
if all_ready {
return Ok(());
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
fn setup_https(project_name: &str, project_id: &str) -> Result<Option<HttpsConfig>> {
use colored::Colorize;
if !check_mkcert_installed() {
println!("{}", "mkcert not found, falling back to HTTP mode".yellow());
println!("Install mkcert for HTTPS support: https://github.com/FiloSottile/mkcert");
return Ok(None);
}
let use_port_443 = if is_port_443_available() {
true
} else if is_project_proxy_on_443(project_id) {
true
} else {
println!(
"{}",
"Port 443 in use by another process, using per-service ports".yellow()
);
false
};
let project_slug = slugify(project_name);
let certs_dir = get_develop_dir(project_id).join("certs");
let config = if certs_exist(&certs_dir, use_port_443) {
get_existing_certs(&project_slug, &certs_dir, use_port_443)
} else {
println!("{}", "Setting up local HTTPS...".cyan());
if let Err(e) = ensure_mkcert_ca() {
println!("{}: {}", "Warning: Failed to install mkcert CA".yellow(), e);
println!("Run 'mkcert -install' manually to trust local certificates");
}
match generate_certs(&project_slug, &certs_dir, use_port_443) {
Ok(config) => {
if use_port_443 {
println!(
" {} Generated wildcard certs for *.{}",
"✓".green(),
config.base_domain
);
} else {
println!(
" {} Generated certs for {}",
"✓".green(),
config.base_domain
);
}
config
}
Err(e) => {
println!(
"{}: {}",
"Warning: Failed to generate certificates".yellow(),
e
);
println!("Falling back to HTTP mode");
return Ok(None);
}
}
};
Ok(Some(config))
}