Skip to content

Commit 6fecb7d

Browse files
committed
Add railpack integration: rp_plan, rp_build, detect_app fallback
Adds two new public functions and extends detect_app: - rp_plan(path): run `railpack plan` and return parsed build plan dict - rp_build(path, tag, buildkit_host, config): build via railpack + BuildKit, supporting PHP, Java, Ruby, .NET, Elixir, Deno, C++, Gleam and more - detect_app gains use_railpack=False: falls back to rp_build for unknown stacks Includes install instructions (mise/brew/binary/scoop) and BuildKit setup, plus examples in index.ipynb and README: apt package deps, custom build commands, and the hybrid workflow (dockeasy generates Dockerfile, railpack builds it). https://claude.ai/code/session_012RMjKQkcXoWnEkDF29rEGR
1 parent 6aa8ed6 commit 6fecb7d

4 files changed

Lines changed: 146 additions & 7 deletions

File tree

README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,65 @@ for files, label in cases:
252252
package.json + pyproject.toml → fastapi_react
253253
pyproject.toml → python_app (port 8000)
254254

255+
### Railpack: build any language
256+
257+
[Railpack](https://railpack.com) is Railway's zero-config image builder. Where
258+
`detect_app` covers Python, Go, Rust and Node, railpack adds **PHP, Java, Ruby,
259+
.NET, Elixir, Deno, C++, Gleam** and more — and builds via BuildKit LLB, so no
260+
Dockerfile is required. It also detects an existing `Dockerfile` in the repo and
261+
uses it directly.
262+
263+
**Install railpack**
264+
265+
| Platform | Command |
266+
|---|---|
267+
| macOS / Linux (mise) | `curl https://mise.run \| sh && mise install github:railwayapp/railpack@latest` |
268+
| macOS (Homebrew) | `brew install railpack` |
269+
| Linux (binary) | `curl -L .../railpack-linux-amd64 -o railpack && chmod +x railpack && sudo mv railpack /usr/local/bin/` |
270+
| Windows (Scoop) | `scoop install railpack` |
271+
272+
**Start BuildKit** (required for `rp_build`):
273+
274+
``` bash
275+
docker run --rm --privileged -d --name buildkit moby/buildkit
276+
export BUILDKIT_HOST='docker-container://buildkit'
277+
```
278+
279+
``` python
280+
# Build a Laravel (PHP) app — detect_app can't handle this, railpack can
281+
rp_build('/path/to/my-laravel-app', tag='myapp:latest')
282+
283+
# detect_app with railpack fallback — no ValueError for unknown stacks
284+
detect_app('/path/to/my-java-app', use_railpack=True, tag='myapp:latest')
285+
286+
# Inspect what railpack detects before building
287+
plan = rp_plan('/path/to/my-node-app')
288+
print(plan['deploy']['startCommand']) # e.g. 'node dist/index.js'
289+
print(plan['packages']) # e.g. {'node': '20.x'}
290+
291+
# Pass apt packages and custom commands — mirrors dockeasy's pkgs= pattern
292+
rp_build('.', tag='myapp:latest', config={
293+
'$schema': 'https://schema.railpack.com',
294+
'steps': {
295+
'install': {'commands': ['apt-get install -y libpq-dev libgdal-dev']}
296+
},
297+
'deploy': {
298+
'startCommand': 'gunicorn app:create_app --bind 0.0.0.0:8000 --workers 4',
299+
'variables': {'DATABASE_POOL_SIZE': '10'}
300+
}
301+
})
302+
303+
# Hybrid: dockeasy generates the Dockerfile, railpack builds it via BuildKit
304+
df = python_app(port=8000, pkgs=['libpq-dev', 'curl'], vols=['/app/data'], healthcheck='/health')
305+
df.save('/path/to/myproject/Dockerfile') # railpack auto-detects this
306+
rp_build('/path/to/myproject', tag='myapp:latest')
307+
308+
# Or load an existing Dockerfile, extend it, then build with railpack
309+
df = Dockerfile.load('/path/to/myproject/Dockerfile')
310+
df.run('apt-get install -y libpq-dev').save('/path/to/myproject/Dockerfile')
311+
rp_build('/path/to/myproject', tag='myapp:v2')
312+
```
313+
255314
## Docker Compose
256315

257316
The [`Compose`](https://Karthik777.github.io/dockeasy/core.html#compose)

dockeasy/core.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# %% auto #0
66
__all__ = ['fasthtml_app', 'mk_flags', 'Dockerfile', 'Cli', 'Docker', 'test', 'drun', 'containers', 'images', 'stop', 'logs',
77
'rm', 'rmi', 'dict2str', 'service', 'Compose', 'python_app', 'fastapi_react', 'go_app', 'rust_app',
8-
'node_app', 'detect_app', 'env_set', 'env_get', 'secret_set', 'secret_get', 'secrets']
8+
'node_app', 'detect_app', 'rp_plan', 'rp_build', 'env_set', 'env_get', 'secret_set', 'secret_get', 'secrets']
99

1010
# %% ../nbs/00_core.ipynb #c7b52454175ab4d4
1111
import re, json, os, yaml, keyring, time
@@ -349,8 +349,8 @@ def node_app(port=3000, node_version='20', cmd=None, build_cmd='npm run build',
349349

350350
fasthtml_app = bind(python_app, port=5001, cmd=['python', 'app.py'], multistage=False)
351351

352-
def detect_app(path='.', multistage=True, **kw):
353-
'A naive project type detector from path and return the appropriate Dockerfile. for **kw lookup other app builders'
352+
def detect_app(path='.', multistage=True, use_railpack=False, **kw):
353+
'Detect project type and return the appropriate Dockerfile. use_railpack=True falls back to rp_build() for unsupported stacks (PHP, Java, Ruby, .NET, Elixir, Deno, C++ etc.) — returns tag str instead of Dockerfile in that case. For **kw see each app builder.'
354354
p = Path(path)
355355
has = lambda f: (p/f).exists()
356356
read = lambda f: (p/f).read_text().lower() if has(f) else ''
@@ -363,7 +363,35 @@ def detect_app(path='.', multistage=True, **kw):
363363
if pyp and 'python-fasthtml' in read('pyproject.toml'): return fasthtml_app(multistage=multistage, **kw)
364364
if pyp: return python_app(multistage=multistage, **kw)
365365
if req: return python_app(multistage=False, req=True, **kw)
366-
raise ValueError(f'Cannot detect project type in {path!r}')
366+
if use_railpack: return rp_build(path=path, **kw)
367+
raise ValueError(f'Cannot detect project type in {path!r}. Try use_railpack=True for PHP, Java, Ruby, .NET, Elixir, Deno etc.')
368+
369+
# %% ../nbs/00_core.ipynb #q08mqwq7vu
370+
def rp_plan(path='.'):
371+
'Run `railpack plan` on path and return the parsed build plan dict. Useful for inspecting detected language, packages, and build steps without building.'
372+
result = run('railpack', 'plan', str(Path(path).resolve()))
373+
return json.loads(result)
374+
375+
def rp_build(path='.', tag=None, buildkit_host=None, config=None):
376+
'Build image from path using railpack + BuildKit. Supports all railpack-detected languages (PHP, Java, Ruby, .NET, Elixir, Deno, C++ etc.). config dict is written as railpack.json if one does not already exist. Returns tag.'
377+
import subprocess
378+
p = Path(path).resolve()
379+
cfg_path = p / 'railpack.json'
380+
wrote_cfg = False
381+
if config and not cfg_path.exists():
382+
cfg_path.write_text(json.dumps(config, indent=2))
383+
wrote_cfg = True
384+
try:
385+
args = ['railpack', 'build']
386+
if tag: args += ['--tag', tag]
387+
args.append(str(p))
388+
env = os.environ.copy()
389+
if buildkit_host: env['BUILDKIT_HOST'] = buildkit_host
390+
res = subprocess.run(args, env=env, capture_output=True)
391+
if res.returncode: raise IOError((res.stdout + b' ;; ' + res.stderr).decode().strip())
392+
finally:
393+
if wrote_cfg: cfg_path.unlink(missing_ok=True)
394+
return tag
367395

368396
# %% ../nbs/00_core.ipynb #5f5a4229dae3d2c4
369397
_FASTOPS_ENV = Path.home() / '.config' / 'fastops' / '.env'

nbs/00_core.ipynb

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,7 +1116,21 @@
11161116
"id": "e17b41b26d006536",
11171117
"metadata": {},
11181118
"outputs": [],
1119-
"source": "#| export\ndef python_app(port=8000, cmd=None, im='python:3.13-slim', wd='/app', pkgs=None,\n vols=None, multistage=True, uv_image='ghcr.io/astral-sh/uv:python3.13-bookworm', healthcheck=None, req=False):\n 'Python app Dockerfile. multistage=True (default): uv-builder → slim. False: single-stage with uv binary copy. req=True: use requirements.txt instead of pyproject.toml (forces multistage=False).'\n if multistage:\n df = Dockerfile().with_uv(uv_image, im, wd).copy(wd, wd, from_='builder').env('PATH', f'{wd}/.venv/bin:$PATH').packages(*listify(pkgs))\n else: df = Dockerfile().from_(im).workdir(wd).packages(*listify(pkgs)).inst_uv(req=req, wd=wd).copy('.', '.')\n if vols: df = df.run('mkdir -p ' + ' '.join(listify(vols)))\n if healthcheck: df = df.healthcheck(f'curl -f http://localhost:{port}{healthcheck}', i='30s', t='5s', r='3')\n _cmd = cmd or ['python', 'main.py']\n return df.expose(port).cmd(_cmd if isinstance(_cmd, list) else _cmd.split())\n\ndef fastapi_react(port=8000, node_version='20', frontend_dir='frontend', build_dir='dist',\n image='python:3.13-slim', pkgs=None, healthcheck='/health', multistage=False,\n uv_image='ghcr.io/astral-sh/uv:python3.13-bookworm'):\n 'Two-stage (default) or three-stage (multistage=True) Dockerfile: Node.js frontend + Python/FastAPI backend'\n df = (Dockerfile().from_(f'node:{node_version}-slim', as_='frontend')\n .workdir('/build').copy(f'{frontend_dir}/package*.json', '.')\n .run('npm ci').copy(frontend_dir, '.').run('npm run build'))\n if multistage: df = df.with_uv(uv_image, image, '/app').copy('/app', '/app', from_='builder').env('PATH', '/app/.venv/bin:$PATH').packages(*listify(pkgs))\n else: df = df.from_(image).workdir('/app').packages(*listify(pkgs)).inst_uv(wd='/app').copy('.', '.')\n df = df.copy(f'/build/{build_dir}', '/app/static', from_='frontend')\n if healthcheck: df = df.healthcheck(f'curl -f http://localhost:{port}{healthcheck}', i='30s', t='5s', r='3')\n return df.expose(port).cmd(['uvicorn', 'main:app', '--host', '0.0.0.0', f'--port={port}'])\n\ndef go_app(port=8080, go_version='1.22', binary='app', runtime='gcr.io/distroless/static', cmd=None, cgo=False):\n 'Two-stage Go Dockerfile: go compiler + go mod cache → distroless runtime'\n df = (Dockerfile().from_(f'golang:{go_version}-alpine', as_='builder')\n .workdir('/src').copy('go.mod', '.').copy('go.sum', '.')\n .run_mount('go mod download', target='/go/pkg/mod')\n .copy('.', '.').env('CGO_ENABLED', '0' if not cgo else '1')\n .run(f'go build -ldflags=\"-s -w\" -o /{binary} .').from_(runtime)\n .copy(f'/{binary}', f'/{binary}', from_='builder').expose(port))\n return df.cmd(cmd or [f'/{binary}'])\n\ndef rust_app(port=8080, rust_version='1', binary='app', runtime='gcr.io/distroless/static', features=None):\n 'Two-stage Rust Dockerfile: cargo build --release → distroless runtime'\n build_cmd = 'cargo build --release' + (f' --features {features}' if features else '')\n df = (Dockerfile().from_(f'rust:{rust_version}-slim-bookworm', as_='builder').workdir('/src')\n .copy('.', '.').run_mount(build_cmd, target='/usr/local/cargo/registry').from_(runtime)\n .copy(f'/src/target/release/{binary}', f'/{binary}', from_='builder').expose(port))\n return df.cmd([f'/{binary}'])\n\ndef node_app(port=3000, node_version='20', cmd=None, build_cmd='npm run build', static=False):\n 'Node.js Dockerfile. static=True does two-stage build → serve dist/; False runs node directly.'\n df = (Dockerfile().from_(f'node:{node_version}-slim', as_='builder' if static else None)\n .workdir('/app').copy('package*.json', '.').run('npm ci').copy('.', '.'))\n if static:\n return (df.run(build_cmd).from_(f'node:{node_version}-slim').workdir('/app')\n .run('npm install -g serve').copy('/app/dist', '.', from_='builder')\n .expose(port).cmd(cmd or ['serve', '-s', '.', '-l', str(port)]))\n return df.expose(port).cmd(cmd or ['node', 'index.js'])\n\nfasthtml_app = bind(python_app, port=5001, cmd=['python', 'app.py'], multistage=False)\n\ndef detect_app(path='.', multistage=True, **kw):\n 'A naive project type detector from path and return the appropriate Dockerfile. for **kw lookup other app builders'\n p = Path(path)\n has = lambda f: (p/f).exists()\n read = lambda f: (p/f).read_text().lower() if has(f) else ''\n pyp, req= has('pyproject.toml'), has('requirements.txt')\n has_py = pyp or req\n if has('Cargo.toml'): return rust_app(**kw)\n if has('go.mod'): return go_app(**kw)\n if has('package.json') and has_py: return fastapi_react(multistage=multistage, **kw)\n if has('package.json'): return node_app(**kw)\n if pyp and 'python-fasthtml' in read('pyproject.toml'): return fasthtml_app(multistage=multistage, **kw)\n if pyp: return python_app(multistage=multistage, **kw)\n if req: return python_app(multistage=False, req=True, **kw)\n raise ValueError(f'Cannot detect project type in {path!r}')"
1119+
"source": "#| export\ndef python_app(port=8000, cmd=None, im='python:3.13-slim', wd='/app', pkgs=None,\n vols=None, multistage=True, uv_image='ghcr.io/astral-sh/uv:python3.13-bookworm', healthcheck=None, req=False):\n 'Python app Dockerfile. multistage=True (default): uv-builder → slim. False: single-stage with uv binary copy. req=True: use requirements.txt instead of pyproject.toml (forces multistage=False).'\n if multistage:\n df = Dockerfile().with_uv(uv_image, im, wd).copy(wd, wd, from_='builder').env('PATH', f'{wd}/.venv/bin:$PATH').packages(*listify(pkgs))\n else: df = Dockerfile().from_(im).workdir(wd).packages(*listify(pkgs)).inst_uv(req=req, wd=wd).copy('.', '.')\n if vols: df = df.run('mkdir -p ' + ' '.join(listify(vols)))\n if healthcheck: df = df.healthcheck(f'curl -f http://localhost:{port}{healthcheck}', i='30s', t='5s', r='3')\n _cmd = cmd or ['python', 'main.py']\n return df.expose(port).cmd(_cmd if isinstance(_cmd, list) else _cmd.split())\n\ndef fastapi_react(port=8000, node_version='20', frontend_dir='frontend', build_dir='dist',\n image='python:3.13-slim', pkgs=None, healthcheck='/health', multistage=False,\n uv_image='ghcr.io/astral-sh/uv:python3.13-bookworm'):\n 'Two-stage (default) or three-stage (multistage=True) Dockerfile: Node.js frontend + Python/FastAPI backend'\n df = (Dockerfile().from_(f'node:{node_version}-slim', as_='frontend')\n .workdir('/build').copy(f'{frontend_dir}/package*.json', '.')\n .run('npm ci').copy(frontend_dir, '.').run('npm run build'))\n if multistage: df = df.with_uv(uv_image, image, '/app').copy('/app', '/app', from_='builder').env('PATH', '/app/.venv/bin:$PATH').packages(*listify(pkgs))\n else: df = df.from_(image).workdir('/app').packages(*listify(pkgs)).inst_uv(wd='/app').copy('.', '.')\n df = df.copy(f'/build/{build_dir}', '/app/static', from_='frontend')\n if healthcheck: df = df.healthcheck(f'curl -f http://localhost:{port}{healthcheck}', i='30s', t='5s', r='3')\n return df.expose(port).cmd(['uvicorn', 'main:app', '--host', '0.0.0.0', f'--port={port}'])\n\ndef go_app(port=8080, go_version='1.22', binary='app', runtime='gcr.io/distroless/static', cmd=None, cgo=False):\n 'Two-stage Go Dockerfile: go compiler + go mod cache → distroless runtime'\n df = (Dockerfile().from_(f'golang:{go_version}-alpine', as_='builder')\n .workdir('/src').copy('go.mod', '.').copy('go.sum', '.')\n .run_mount('go mod download', target='/go/pkg/mod')\n .copy('.', '.').env('CGO_ENABLED', '0' if not cgo else '1')\n .run(f'go build -ldflags=\"-s -w\" -o /{binary} .').from_(runtime)\n .copy(f'/{binary}', f'/{binary}', from_='builder').expose(port))\n return df.cmd(cmd or [f'/{binary}'])\n\ndef rust_app(port=8080, rust_version='1', binary='app', runtime='gcr.io/distroless/static', features=None):\n 'Two-stage Rust Dockerfile: cargo build --release → distroless runtime'\n build_cmd = 'cargo build --release' + (f' --features {features}' if features else '')\n df = (Dockerfile().from_(f'rust:{rust_version}-slim-bookworm', as_='builder').workdir('/src')\n .copy('.', '.').run_mount(build_cmd, target='/usr/local/cargo/registry').from_(runtime)\n .copy(f'/src/target/release/{binary}', f'/{binary}', from_='builder').expose(port))\n return df.cmd([f'/{binary}'])\n\ndef node_app(port=3000, node_version='20', cmd=None, build_cmd='npm run build', static=False):\n 'Node.js Dockerfile. static=True does two-stage build → serve dist/; False runs node directly.'\n df = (Dockerfile().from_(f'node:{node_version}-slim', as_='builder' if static else None)\n .workdir('/app').copy('package*.json', '.').run('npm ci').copy('.', '.'))\n if static:\n return (df.run(build_cmd).from_(f'node:{node_version}-slim').workdir('/app')\n .run('npm install -g serve').copy('/app/dist', '.', from_='builder')\n .expose(port).cmd(cmd or ['serve', '-s', '.', '-l', str(port)]))\n return df.expose(port).cmd(cmd or ['node', 'index.js'])\n\nfasthtml_app = bind(python_app, port=5001, cmd=['python', 'app.py'], multistage=False)\n\ndef detect_app(path='.', multistage=True, use_railpack=False, **kw):\n 'Detect project type and return the appropriate Dockerfile. use_railpack=True falls back to rp_build() for unsupported stacks (PHP, Java, Ruby, .NET, Elixir, Deno, C++ etc.) — returns tag str instead of Dockerfile in that case. For **kw see each app builder.'\n p = Path(path)\n has = lambda f: (p/f).exists()\n read = lambda f: (p/f).read_text().lower() if has(f) else ''\n pyp, req= has('pyproject.toml'), has('requirements.txt')\n has_py = pyp or req\n if has('Cargo.toml'): return rust_app(**kw)\n if has('go.mod'): return go_app(**kw)\n if has('package.json') and has_py: return fastapi_react(multistage=multistage, **kw)\n if has('package.json'): return node_app(**kw)\n if pyp and 'python-fasthtml' in read('pyproject.toml'): return fasthtml_app(multistage=multistage, **kw)\n if pyp: return python_app(multistage=multistage, **kw)\n if req: return python_app(multistage=False, req=True, **kw)\n if use_railpack: return rp_build(path=path, **kw)\n raise ValueError(f'Cannot detect project type in {path!r}. Try use_railpack=True for PHP, Java, Ruby, .NET, Elixir, Deno etc.')"
1120+
},
1121+
{
1122+
"cell_type": "markdown",
1123+
"id": "wcnw7e3kg8",
1124+
"source": "## Railpack integration\n\n[Railpack](https://railpack.com) builds images via BuildKit LLB — no Dockerfile needed. It auto-detects 12+ languages including PHP, Java, Ruby, .NET, Elixir, Deno and more. Requires the `railpack` binary and a running BuildKit daemon.",
1125+
"metadata": {}
1126+
},
1127+
{
1128+
"cell_type": "code",
1129+
"id": "q08mqwq7vu",
1130+
"source": "#| export\ndef rp_plan(path='.'):\n 'Run `railpack plan` on path and return the parsed build plan dict. Useful for inspecting detected language, packages, and build steps without building.'\n result = run('railpack', 'plan', str(Path(path).resolve()))\n return json.loads(result)\n\ndef rp_build(path='.', tag=None, buildkit_host=None, config=None):\n 'Build image from path using railpack + BuildKit. Supports all railpack-detected languages (PHP, Java, Ruby, .NET, Elixir, Deno, C++ etc.). config dict is written as railpack.json if one does not already exist. Returns tag.'\n import subprocess\n p = Path(path).resolve()\n cfg_path = p / 'railpack.json'\n wrote_cfg = False\n if config and not cfg_path.exists():\n cfg_path.write_text(json.dumps(config, indent=2))\n wrote_cfg = True\n try:\n args = ['railpack', 'build']\n if tag: args += ['--tag', tag]\n args.append(str(p))\n env = os.environ.copy()\n if buildkit_host: env['BUILDKIT_HOST'] = buildkit_host\n res = subprocess.run(args, env=env, capture_output=True)\n if res.returncode: raise IOError((res.stdout + b' ;; ' + res.stderr).decode().strip())\n finally:\n if wrote_cfg: cfg_path.unlink(missing_ok=True)\n return tag",
1131+
"metadata": {},
1132+
"execution_count": null,
1133+
"outputs": []
11201134
},
11211135
{
11221136
"cell_type": "markdown",
@@ -1403,4 +1417,4 @@
14031417
},
14041418
"nbformat": 4,
14051419
"nbformat_minor": 5
1406-
}
1420+
}

0 commit comments

Comments
 (0)