Skip to content

Commit 8cfc05c

Browse files
committed
feat(build): dockerfile: and context: for subdir/monorepo Dockerfiles
teploy's builder only ever built ./Dockerfile with the project root as context — no way to build a monorepo whose Dockerfile lives in a subdirectory, or to give a subdir Dockerfile a wider context. Adds two config fields: context: build context dir, relative to teploy.yml (default ".") dockerfile: Dockerfile path, relative to context (default "Dockerfile") Both server-side and local (build_local) paths honor them, passing docker build -f / an explicit context only when customized — the default command is byte-identical, so nothing changes for the common case. DetectAt resolves the mode at the right path and errors on an explicitly-named-but-missing Dockerfile rather than silently falling back to Nixpacks. context/dockerfile are validated as safe relative paths (no absolute, no .. escaping) and rejected alongside image: (pulled, not built) or type:static. Closes the one real gap behind "teploy can't build akiroo's subdir Dockerfile" — though CI-prebuild + registry pull sidesteps it entirely.
1 parent 39a4389 commit 8cfc05c

6 files changed

Lines changed: 508 additions & 51 deletions

File tree

‎README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,17 @@ platform: linux/amd64
105105
stop_timeout: 30
106106
keep_versions: 3 # auto-prune older versions after deploy (0 = keep all, default)
107107

108+
# Build a Dockerfile that lives in a subdirectory (monorepos). Omit both
109+
# to use ./Dockerfile with the project root as context (the default).
110+
# `dockerfile` is resolved relative to `context`; `context` is relative to
111+
# this teploy.yml. Example: a Dockerfile under server/ that COPYs shared
112+
# packages from the repo root —
113+
# context: .
114+
# dockerfile: server/monolith/Dockerfile
115+
# Or build just one workspace of a monorepo —
116+
# context: apps/api
117+
# These apply only when Teploy builds the image (no `image:` set).
118+
108119
# Custom TLS cert — e.g. Cloudflare Origin Certificate behind a CF-proxied
109120
# domain where ACME can't reach the origin. Cert + key are LOCAL file paths,
110121
# uploaded to the server on deploy. Default is ACME (automatic HTTPS).

‎internal/build/build.go‎

Lines changed: 114 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"io"
77
"os"
88
"os/exec"
9+
"path"
910
"path/filepath"
1011
"runtime"
1112

@@ -35,10 +36,33 @@ func (m Mode) String() string {
3536
// Detect examines the directory and returns the appropriate build mode.
3637
// Priority: Dockerfile → Nixpacks fallback.
3738
func Detect(dir string) Mode {
38-
if _, err := os.Stat(filepath.Join(dir, "Dockerfile")); err == nil {
39-
return ModeDockerfile
39+
mode, _ := DetectAt(dir, "")
40+
return mode
41+
}
42+
43+
// DetectAt resolves the build mode for a given context directory and
44+
// optional Dockerfile (relative to the context; empty means "Dockerfile").
45+
// A Dockerfile at the resolved path selects ModeDockerfile. When the caller
46+
// named a Dockerfile explicitly and it is missing, that is an error rather
47+
// than a silent fall-through to Nixpacks — an explicit path that points at
48+
// nothing is a config mistake worth surfacing. With no explicit Dockerfile
49+
// and none present, it falls back to ModeNixpacks.
50+
func DetectAt(contextDir, dockerfile string) (Mode, error) {
51+
if contextDir == "" {
52+
contextDir = "."
53+
}
54+
name := dockerfile
55+
if name == "" {
56+
name = "Dockerfile"
4057
}
41-
return ModeNixpacks
58+
resolved := filepath.Join(contextDir, name)
59+
if _, err := os.Stat(resolved); err == nil {
60+
return ModeDockerfile, nil
61+
}
62+
if dockerfile != "" {
63+
return ModeNone, fmt.Errorf("dockerfile %q not found (looked at %s)", dockerfile, resolved)
64+
}
65+
return ModeNixpacks, nil
4266
}
4367

4468
// ImageTag returns the image tag used for server-built images.
@@ -51,8 +75,14 @@ type BuildConfig struct {
5175
App string
5276
Version string
5377
Mode Mode
54-
BuildDir string // remote directory containing the source
55-
Platform string // e.g. "linux/arm64" (optional)
78+
BuildDir string // remote directory containing the synced source
79+
// Context is the build-context subdirectory relative to BuildDir
80+
// (empty or "." means BuildDir itself).
81+
Context string
82+
// Dockerfile is the Dockerfile path relative to Context (empty means
83+
// "Dockerfile" in the context root).
84+
Dockerfile string
85+
Platform string // e.g. "linux/arm64" (optional)
5686
}
5787

5888
// Builder runs Docker or Nixpacks builds on the server via SSH.
@@ -72,34 +102,70 @@ func (b *Builder) Build(ctx context.Context, cfg BuildConfig) (string, error) {
72102

73103
switch cfg.Mode {
74104
case ModeDockerfile:
75-
return tag, b.buildDockerfile(ctx, tag, cfg.BuildDir, cfg.Platform)
105+
return tag, b.buildDockerfile(ctx, tag, cfg.BuildDir, cfg.Context, cfg.Dockerfile, cfg.Platform)
76106
case ModeNixpacks:
77-
return tag, b.buildNixpacks(ctx, tag, cfg.App, cfg.BuildDir)
107+
return tag, b.buildNixpacks(ctx, tag, cfg.App, cfg.BuildDir, cfg.Context)
78108
default:
79109
return "", fmt.Errorf("unknown build mode: %s", cfg.Mode)
80110
}
81111
}
82112

83-
func (b *Builder) buildDockerfile(ctx context.Context, tag, buildDir, platform string) error {
113+
func (b *Builder) buildDockerfile(ctx context.Context, tag, buildDir, contextSub, dockerfile, platform string) error {
84114
cmd := "docker build -t " + tag
85115
if platform != "" {
86116
cmd += " --platform " + platform
87117
}
88-
cmd += " " + buildDir
118+
// Remote paths are POSIX; use path.Join and quote for the shell.
119+
cmd += remoteBuildTail(buildDir, contextSub, dockerfile)
89120
return b.exec.RunStream(ctx, cmd, b.stdout, b.stdout)
90121
}
91122

92-
func (b *Builder) buildNixpacks(ctx context.Context, tag, app, buildDir string) error {
123+
func (b *Builder) buildNixpacks(ctx context.Context, tag, app, buildDir, contextSub string) error {
93124
// Ensure Nixpacks is installed (lazy installation).
94125
if err := b.ensureNixpacks(ctx); err != nil {
95126
return err
96127
}
97128

98129
cachePath := fmt.Sprintf("/deployments/%s/cache", app)
99-
cmd := fmt.Sprintf("nixpacks build %s --name %s --cache-path %s", buildDir, tag, cachePath)
130+
buildTarget := subDir(path.Join, buildDir, contextSub)
131+
cmd := fmt.Sprintf("nixpacks build %s --name %s --cache-path %s", buildTarget, tag, cachePath)
100132
return b.exec.RunStream(ctx, cmd, b.stdout, b.stdout)
101133
}
102134

135+
// subDir joins an optional context subdir onto a root, returning the root
136+
// unchanged for "" or ".". join is path.Join for remote (POSIX) paths and
137+
// filepath.Join for local ones.
138+
func subDir(join func(...string) string, root, contextSub string) string {
139+
if contextSub == "" || contextSub == "." {
140+
return root
141+
}
142+
return join(root, contextSub)
143+
}
144+
145+
// remoteBuildTail builds the trailing docker-build arguments for a remote
146+
// (shell-string) build: an optional `-f <dockerfile>` plus the context dir.
147+
// When neither Context nor Dockerfile is customized it returns exactly
148+
// " <root>" — byte-identical to the long-standing default command — so the
149+
// common case is unchanged. Custom paths are quoted for the remote shell.
150+
func remoteBuildTail(root, contextSub, dockerfile string) string {
151+
if contextSub == "" && dockerfile == "" {
152+
return " " + root
153+
}
154+
contextDir := subDir(path.Join, root, contextSub)
155+
tail := ""
156+
if dockerfile != "" {
157+
if dfPath := path.Join(contextDir, dockerfile); dfPath != path.Join(contextDir, "Dockerfile") {
158+
tail += " -f " + ssh.ShellQuote(dfPath)
159+
}
160+
}
161+
if contextDir == root {
162+
tail += " " + root
163+
} else {
164+
tail += " " + ssh.ShellQuote(contextDir)
165+
}
166+
return tail
167+
}
168+
103169
func (b *Builder) ensureNixpacks(ctx context.Context) error {
104170
if _, err := b.exec.Run(ctx, "which nixpacks"); err == nil {
105171
return nil
@@ -111,15 +177,21 @@ func (b *Builder) ensureNixpacks(ctx context.Context) error {
111177

112178
// LocalBuildConfig holds parameters for building locally and streaming to server.
113179
type LocalBuildConfig struct {
114-
App string
115-
Version string
116-
Mode Mode
117-
Dir string // local source directory
118-
Host string
119-
User string
120-
KeyPath string
121-
Platform string // e.g. "linux/arm64" (optional, overrides auto-detection)
122-
Exec ssh.Executor // optional: if set, enables layer-optimized transfer
180+
App string
181+
Version string
182+
Mode Mode
183+
Dir string // local source directory
184+
// Context is the build-context subdirectory relative to Dir (empty or
185+
// "." means Dir itself).
186+
Context string
187+
// Dockerfile is the Dockerfile path relative to Context (empty means
188+
// "Dockerfile" in the context root).
189+
Dockerfile string
190+
Host string
191+
User string
192+
KeyPath string
193+
Platform string // e.g. "linux/arm64" (optional, overrides auto-detection)
194+
Exec ssh.Executor // optional: if set, enables layer-optimized transfer
123195
}
124196

125197
// LocalBuild builds the image on the local machine, then streams it to the
@@ -130,11 +202,11 @@ func LocalBuild(ctx context.Context, cfg LocalBuildConfig, stdout io.Writer) (st
130202
// Build locally.
131203
switch cfg.Mode {
132204
case ModeDockerfile:
133-
if err := localBuildDockerfile(ctx, tag, cfg.Dir, cfg.Platform, stdout); err != nil {
205+
if err := localBuildDockerfile(ctx, tag, cfg.Dir, cfg.Context, cfg.Dockerfile, cfg.Platform, stdout); err != nil {
134206
return "", err
135207
}
136208
case ModeNixpacks:
137-
if err := localBuildNixpacks(ctx, tag, cfg.Dir, stdout); err != nil {
209+
if err := localBuildNixpacks(ctx, tag, subDir(filepath.Join, cfg.Dir, cfg.Context), stdout); err != nil {
138210
return "", err
139211
}
140212
default:
@@ -160,7 +232,7 @@ func LocalBuild(ctx context.Context, cfg LocalBuildConfig, stdout io.Writer) (st
160232
return tag, nil
161233
}
162234

163-
func localBuildDockerfile(ctx context.Context, tag, dir, platform string, stdout io.Writer) error {
235+
func localBuildDockerfile(ctx context.Context, tag, dir, contextSub, dockerfile, platform string, stdout io.Writer) error {
164236
args := []string{"build", "-t", tag}
165237

166238
if platform != "" {
@@ -171,7 +243,8 @@ func localBuildDockerfile(ctx context.Context, tag, dir, platform string, stdout
171243
args = append(args, "--platform", "linux/amd64")
172244
}
173245

174-
args = append(args, dir)
246+
// exec.Command takes an argv, so no shell quoting is needed here.
247+
args = localBuildTail(args, dir, contextSub, dockerfile)
175248
cmd := exec.CommandContext(ctx, "docker", args...)
176249
cmd.Stdout = stdout
177250
cmd.Stderr = stdout
@@ -181,6 +254,23 @@ func localBuildDockerfile(ctx context.Context, tag, dir, platform string, stdout
181254
return nil
182255
}
183256

257+
// localBuildTail appends the trailing docker-build arguments for a local
258+
// (argv) build: an optional `-f <dockerfile>` plus the context dir. Mirrors
259+
// remoteBuildTail but produces argv entries (no shell quoting) using host
260+
// path semantics.
261+
func localBuildTail(args []string, dir, contextSub, dockerfile string) []string {
262+
if contextSub == "" && dockerfile == "" {
263+
return append(args, dir)
264+
}
265+
contextDir := subDir(filepath.Join, dir, contextSub)
266+
if dockerfile != "" {
267+
if dfPath := filepath.Join(contextDir, dockerfile); dfPath != filepath.Join(contextDir, "Dockerfile") {
268+
args = append(args, "-f", dfPath)
269+
}
270+
}
271+
return append(args, contextDir)
272+
}
273+
184274
func localBuildNixpacks(ctx context.Context, tag, dir string, stdout io.Writer) error {
185275
cmd := exec.CommandContext(ctx, "nixpacks", "build", dir, "--name", tag)
186276
cmd.Stdout = stdout

0 commit comments

Comments
 (0)