Repository navigation
Expand file tree
/
Copy pathDockerfile
More file actions
214 lines (186 loc) · 9.07 KB
/
Copy pathDockerfile
File metadata and controls
214 lines (186 loc) · 9.07 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# ==============================================
# Multi-stage build for cloud-agnostic deployment
# Works on: AWS Lambda, AWS Fargate, GCP Cloud Run, Azure Container Apps
# Supports: ARM64 (default) and AMD64 architectures
# ==============================================
# Build arguments for multi-architecture support
# TARGETARCH and TARGETOS are set automatically by docker buildx
ARG TARGETARCH
ARG TARGETOS=linux
# Build stage
# Image pinned to a SHA256 digest for reproducible builds — a registry
# tag mutation (Docker Hub allows re-tagging) cannot poison this build.
# To refresh: `docker buildx imagetools inspect golang:1.26.6-alpine3.24`
# (or use the Docker Hub API tags endpoint) and update the digest below.
# A Renovate / Dependabot config can automate this if desired.
FROM --platform=$BUILDPLATFORM golang:1.26.6-alpine3.24@sha256:3889b425f035be855a72fb4755265311293b6d414521f0a519d819df32222d83 AS builder
# Re-declare args for use in this stage
ARG TARGETARCH
ARG TARGETOS
# Build metadata stamped into the binary via ldflags and surfaced by the
# public GET /version endpoint. GIT_COMMIT and BUILD_DATE are supplied by the
# terraform build module (modules/build); they default to "unknown" so a bare
# `docker build .` still succeeds without git context.
ARG VERSION=dev
ARG GIT_COMMIT=unknown
ARG BUILD_DATE
# Install build dependencies
RUN apk add --no-cache \
git \
ca-certificates \
postgresql-client
# Set shell with pipefail for safer pipe operations
SHELL ["/bin/ash", "-eo", "pipefail", "-c"]
WORKDIR /app
# Copy go module files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Build golang-migrate from source on this stage's pinned Go toolchain, the same
# way `make install-tools` does. Upstream's prebuilt release tarballs carry
# whatever toolchain upstream built them with (v4.19.1 ships go1.25.4), which is
# how issue #1833's stdlib CVEs reached the runtime image, where entrypoint.sh
# runs `migrate up` against the database on every container start.
#
# Built as a package of THIS module (no @version suffix) rather than
# `go install ...@v4.19.1`, and therefore placed after `go mod download`.
# The @version form resolves dependencies from golang-migrate's own go.mod,
# which pins jackc/pgx v5.5.4, x/crypto v0.45.0 and x/text v0.31.0 - all
# superseded, and together 17 advisories with published fixes that
# scripts/scan-shipped-image.sh correctly fails on. Building from the main
# module instead resolves them at this repo's own versions, which are already
# above every one of those fixes. The migrate version therefore comes from
# go.mod, and is read back out of the build list rather than restated.
#
# -tags=pgx5, not postgres: the postgres tag links the lib/pq driver, which
# carries advisories with no fixed version in any release (GO-2026-6166, 6168,
# 6170, 6171, 6172) reached through Driver.Open and conn.Exec on every
# container start. The pgx5 tag builds the same driver on jackc/pgx v5, which
# the application already uses. It registers the "pgx5" URL scheme only, so
# scripts/entrypoint.sh and .github/workflows/database-migration.yml must build
# pgx5:// URLs - a postgres:// URL against this binary fails at runtime with
# "unknown driver". See issue #1849.
RUN MIGRATE_VERSION="$(go list -m -f '{{.Version}}' github.com/golang-migrate/migrate/v4)" && \
echo "Building golang-migrate ${MIGRATE_VERSION} for ${TARGETOS}/${TARGETARCH}" && \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
-tags=pgx5 \
-ldflags="-s -w -X main.Version=${MIGRATE_VERSION}" \
-o /usr/local/bin/migrate \
github.com/golang-migrate/migrate/v4/cmd/migrate
# Copy source code
COPY . .
# Build unified server binary (cloud-agnostic)
# Supports both ARM64 and AMD64 via build args
# Default: ARM64 for cost optimization (20% savings on AWS Fargate)
RUN echo "Building for ${TARGETOS}/${TARGETARCH}" && \
BUILD_TIME="${BUILD_DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" && \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
-p 6 \
-ldflags="-s -w -X main.Version=${VERSION:-dev} -X main.BuildTime=${BUILD_TIME} -X main.GitSHA=${GIT_COMMIT:-unknown}" \
-o /app/cudly \
./cmd/server
# Binary built successfully
# ==============================================
# Frontend build stage
# ==============================================
# Image pinned to a SHA256 digest for reproducible builds. Refresh via
# the Docker Hub API tags endpoint and update the digest below when the
# `node:24-alpine` tag is bumped.
FROM --platform=$BUILDPLATFORM node:24-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS frontend-builder
WORKDIR /frontend
COPY frontend/package*.json ./
# `--no-progress`: disables the npm progress reporter, whose worker has a
# long-standing race condition in npm 10/11 that surfaces as
# `npm error Exit handler never called!` on memory-constrained hosts
# (Linux VMs, low-RAM CI runners). See npm/cli issues for the bug; the
# fix is "don't run that worker".
# `--maxsockets 1`: serialise registry fetches so peak memory during the
# install stays low. With 810 lockfile entries, parallel fetch + the
# gzip-decode workers race the OOM killer on hosts with <2 GB free.
# `--no-audit --no-fund`: skip post-install network calls that aren't
# relevant to a build context.
# `test -x`: existing guard against silent zero-exit npm failures
# (kept from #044dc583c — addresses a different failure mode where
# npm exits 0 but leaves node_modules empty).
RUN npm ci --no-progress --maxsockets 1 --no-audit --no-fund && \
test -x node_modules/.bin/webpack
COPY frontend/ ./
# webpack.config.js copies ../internal/api/openapi.yaml into the bundle.
COPY internal/api/openapi.yaml /internal/api/openapi.yaml
RUN npm run build
# ==============================================
# Runtime stage - multi-arch base image
# ==============================================
# Image pinned to a SHA256 digest for reproducible builds.
# To refresh: `docker buildx imagetools inspect alpine:3.24.1` and update the
# digest below. This is the multi-arch index digest, not a per-platform one, so
# it stays correct for every TARGETARCH this image is built for.
FROM alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
# Re-declare args for use in this stage
ARG TARGETARCH
ARG TARGETOS
# Install runtime dependencies
RUN apk add --no-cache \
ca-certificates \
postgresql-client \
curl \
tzdata
# Create non-root user for security
RUN addgroup -g 1000 cudly && \
adduser -D -u 1000 -G cudly cudly
# Create app directory
WORKDIR /app
# Copy binary, migrations, and frontend from build stages
COPY --from=builder --chown=cudly:cudly /app/cudly /app/cudly
COPY --from=builder --chown=cudly:cudly /usr/local/bin/migrate /usr/local/bin/migrate
COPY --chown=cudly:cudly internal/database/postgres/migrations /app/migrations
COPY --from=frontend-builder --chown=cudly:cudly /frontend/dist /app/static
# Copy unified entrypoint script and set permissions
COPY --chown=cudly:cudly scripts/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Switch to non-root user. Numeric form so the identity is resolvable without
# the image's /etc/passwd: Kubernetes `runAsNonRoot` and similar admission
# checks cannot verify a name-form USER and will refuse to start the pod.
# 1000:1000 is exactly the uid:gid created above, so this is a rename only.
USER 1000:1000
# Environment defaults
ENV DB_MIGRATIONS_PATH=/app/migrations \
DB_AUTO_MIGRATE=true \
RUNTIME_MODE=auto \
PORT=8080 \
STATIC_DIR=/app/static \
GOARCH=${TARGETARCH} \
GOOS=${TARGETOS}
# Expose HTTP port (used by Fargate, Cloud Run, Container Apps)
# Lambda ignores this
EXPOSE 8080
# Health check (works for HTTP mode, ignored in Lambda mode)
#
# JSON (exec) form, but invoking /bin/sh explicitly: the `||` is a shell
# operator, so a bare exec-form list would hand `||` and `exit` to curl as
# literal arguments and the healthcheck would never report unhealthy correctly.
# This is the same process tree the shell form produced, written so the
# dependency on a shell is declared rather than implied.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD ["/bin/sh", "-c", "curl -f http://localhost:8080/health || exit 1"]
# Unified entrypoint handles both Lambda and HTTP modes
ENTRYPOINT ["/entrypoint.sh"]
CMD ["/app/cudly"]
# ==============================================
# Build Instructions:
# ==============================================
#
# Build for ARM64 (AWS Lambda/Fargate with Graviton):
# docker buildx build --platform linux/arm64 -t cudly:arm64 .
#
# Build for AMD64 (GCP Cloud Run, Azure Container Apps):
# docker buildx build --platform linux/amd64 -t cudly:amd64 .
#
# CI/CD builds (GitHub Actions):
# AWS Lambda/Fargate: --platform linux/arm64 (Graviton2, 20% cost savings)
# GCP Cloud Run: --platform linux/amd64 (ARM64 not supported)
# Azure Container Apps: --platform linux/amd64 (ARM64 not supported)
#
# Build and load for local testing:
# docker buildx build --platform linux/arm64 -t cudly:arm64 --load .
# ==============================================