diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b235f35 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.github +.ferrite + +backend/target +frontend/node_modules +frontend/dist + +data +docs + +*.log +*.tmp diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0976deb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM node:20-bookworm-slim AS frontend-builder +WORKDIR /src/frontend + +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci + +COPY frontend/ ./ +RUN npm run build + +FROM rust:1.85-bookworm AS backend-builder +WORKDIR /src/backend + +COPY backend/ ./ +RUN cargo build --release + +FROM debian:bookworm-slim +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates ripgrep \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=backend-builder /src/backend/target/release/ironpad /app/ironpad +COPY --from=frontend-builder /src/frontend/dist /app/static + +RUN mkdir -p /app/data + +ENV IRONPAD_HOST=0.0.0.0 +ENV IRONPAD_PORT=3000 +ENV IRONPAD_DISABLE_TRAY=1 +ENV IRONPAD_DATA_DIR=/app/data +ENV RUST_LOG=info + +EXPOSE 3000 +VOLUME ["/app/data"] + +CMD ["./ironpad"] diff --git a/README.md b/README.md index 94c28a7..38612b9 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,31 @@ npm run dev Open http://localhost:5173 in your browser. +### Option 3: Run with Docker (Centralized Server) + +This runs Ironpad as a single container that serves both API and frontend on port `3000`. + +```bash +# Build and start in the background +docker compose up -d --build + +# View logs +docker compose logs -f +``` + +Then open: + +- `http://localhost:3000` from the same machine, or +- `http://:3000` from another device on your network. + +Data persists in `./data` on the host via the compose volume mapping. + +To stop: + +```bash +docker compose down +``` + ## Tech Stack ![Tech Stack](docs/graphics/tech-stack.png) @@ -121,6 +146,9 @@ Read about the method: |---------|---------|-------------| | Data directory | `data/` next to executable | Override with `IRONPAD_DATA_DIR` env var | | Backend port | 3000 (auto-increments to 3010) | Dynamic port selection | +| Backend host | `127.0.0.1` | Override with `IRONPAD_HOST` (use `0.0.0.0` for Docker/server access) | +| Fixed port | disabled | Set `IRONPAD_PORT` to force a specific port | +| Disable tray mode | `false` | Set `IRONPAD_DISABLE_TRAY=1` to run headless in production static mode | | Auto-commit | Every 60 seconds | Git commits when changes exist | | Auto-save | 1 second debounce | Frontend saves after typing stops | diff --git a/backend/src/main.rs b/backend/src/main.rs index 96cc76d..ee549b3 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,7 +1,7 @@ // Hide console window on Windows in release builds (production mode) #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use std::net::SocketAddr; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::Arc; use axum::{routing::get, Router}; @@ -20,22 +20,58 @@ mod websocket; /// Find an available port and return the bound listener. /// Avoids TOCTOU race by keeping the listener alive. async fn find_available_port() -> (TcpListener, u16) { + let host = std::env::var("IRONPAD_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + let bind_ip = match host.parse::() { + Ok(ip) => ip, + Err(_) => { + warn!("Invalid IRONPAD_HOST '{}', falling back to 127.0.0.1", host); + IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)) + } + }; + + if let Ok(port_str) = std::env::var("IRONPAD_PORT") { + match port_str.parse::() { + Ok(port) => { + let addr = SocketAddr::new(bind_ip, port); + let listener = TcpListener::bind(addr) + .await + .unwrap_or_else(|e| panic!("Failed to bind to {addr}: {e}")); + return (listener, port); + } + Err(_) => { + warn!("Invalid IRONPAD_PORT '{}', falling back to 3000-3010", port_str); + } + } + } + for port in 3000..=3010 { - let addr = SocketAddr::from(([127, 0, 0, 1], port)); + let addr = SocketAddr::new(bind_ip, port); if let Ok(listener) = TcpListener::bind(addr).await { return (listener, port); } } - panic!("No available ports in range 3000–3010"); + panic!("No available ports in range 3000-3010"); +} + +fn env_flag(name: &str) -> bool { + std::env::var(name) + .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) + .unwrap_or(false) } fn main() { tracing_subscriber::fmt().init(); config::init_data_dir(); - if config::is_production() { + let production = config::is_production(); + let disable_tray = env_flag("IRONPAD_DISABLE_TRAY"); + + if production && !disable_tray { run_with_tray(); } else { + if production && disable_tray { + info!("Production static mode detected; running headless (IRONPAD_DISABLE_TRAY=1)"); + } // Development mode: normal tokio runtime, no tray let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); rt.block_on(run_server(None)); @@ -130,7 +166,11 @@ async fn run_server(port_tx: Option>) { } // Start server - info!("Ironpad running on http://localhost:{port}"); + let bound_addr = listener + .local_addr() + .map(|a| a.to_string()) + .unwrap_or_else(|_| format!("127.0.0.1:{port}")); + info!("Ironpad running on http://{bound_addr}"); axum::serve(listener, app).await.expect("Server failed"); } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d1fc894 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + ironpad: + container_name: ironpad + restart: unless-stopped + + build: + context: . + dockerfile: Dockerfile + + environment: + IRONPAD_HOST: "0.0.0.0" + IRONPAD_PORT: "3000" + IRONPAD_DISABLE_TRAY: "1" + IRONPAD_DATA_DIR: "/app/data" + RUST_LOG: "info" + + ports: + - "3000:3000" + + volumes: + - ./data:/app/data