Build-Time Customization Without Losing Security
CleanStart -prod images are minimalist, hardened, and ready for immediate deployment. But every organization needs to add their own code, configurations, and certificates. Customization happens at build time, never at runtime — preserving the read-only, shell-less security model throughout deployment.
graph TB Base["CleanStart Base<br/>Minimal<br/>Secure<br/>Read-only"] Base -->|Build-time| Custom["Customization<br/>Certs<br/>Config<br/>Code"] Custom -->|Create new image| Prod["Production Image<br/>Hardened<br/>Customized<br/>Signed"] Prod -->|Deploy| Run["Running Container<br/>Read-only<br/>Verified<br/>No drift"] Run -->|Immutable| Safe["✅ Security<br/>preserved"] style Safe fill:#ccffccPart 1: The Customization Dilemma
The Problem Statement
You want to deploy cleanstart/postgresql:15-prod, but your organization needs specific CA certificates for TLS client connections, custom postgresql.conf settings for performance tuning, pre-loaded data or initialization scripts, and your company's custom binary tools like monitoring agents and compliance scanners.
However, if you modify the image after it's built, you will break the read-only filesystem contract. You lose the ability to verify image signatures. The running instance will diverge from the deployed image, creating container drift. You violate supply chain compliance requirements like SolarWinds and SLSA.
The Traditional (Broken) Approach
The naive approach of pulling a shell inside the container and manually editing files appears simple but breaks security fundamentally. You might run docker run -it cleanstart/postgresql:15-prod /bin/bash, navigate to /etc/postgresql, and edit postgresql.conf with an editor like vi. However, this approach requires a shell (breaking the shell-less contract), creates an unauthenticated image with no cosign signature, is irreproducible (generating the same image is impossible), and is not auditable (no record of who changed what or why).
The CleanStart Approach: Build-Time Customization
Customization happens BEFORE the image is deployed through three patterns that preserve security at runtime. cleanimg-customize adds files, certs, and configs as an overlay layer. Multi-stage build adds code, binaries, and compiled artifacts. Kubernetes overlays handles per-environment configuration without rebuild. All three preserve the read-only, shell-less security model at runtime.
Part 2: Three Customization Approaches (Decision Matrix)
Approach 1: cleanimg-customize (Recommended for Configs/Certs)
cleanimg-customize is a tool that takes a CleanStart image and adds an overlay layer containing files you want to add like certs, configs, and binaries. The modified image is re-signed with your key. Runtime remains read-only and shell-less.
Use this approach when adding CA certificates, configuration files, small tools or utilities, or customizing without compiling code. The speed is excellent (seconds instead of minutes), and security is preserved (image remains read-only, shell-less, signed).
Example 1: PostgreSQL with Custom CA Certificates
Setup:
# Create a directory with your customizationsmkdir postgres-customcd postgres-custom # Add your CA certificatemkdir -p etc/ssl/certscp /path/to/corporate-ca.crt etc/ssl/certs/ # Add custom postgresql.confmkdir -p etc/postgresqlcat > etc/postgresql/custom.conf << 'EOF'# Custom settingsmax_connections = 200shared_buffers = 512MBwork_mem = 4MBEOFBuild the customized image:
# Using cleanimg-customize v0.3.0 (spec-based approach)cat > postgres-spec.yaml <<EOFbase_image: "cleanstart/postgresql:15-prod@sha256:abc123..."arch: amd64variant: prodcopy_files: - source: certs/corporate-ca.crt destination: /etc/ssl/certs/corporate-ca.crt - source: config/custom.conf destination: /etc/postgresql/custom.confEOFcleanimg-customize build --spec postgres-spec.yaml --tag myregistry/postgres:15-custom --pushOr using a Dockerfile (multi-stage):
# Stage 1: Base CleanStart image (read-only, shell-less at runtime)FROM cleanstart/postgresql:15-prod@sha256:abc123... # Copy certificates and configs from build context# These are added as a new OCI layerCOPY certs/corporate-ca.crt /etc/ssl/certs/corporate-ca.crtCOPY config/custom.conf /etc/postgresql/custom.conf # The final image is still read-only, shell-less# (no RUN shell commands, no ENTRYPOINT change)Push and verify:
docker push myregistry/postgres:15-customcosign sign --key cosign.key myregistry/postgres:15-custom@sha256:...At runtime (Kubernetes):
containers:- name: postgres image: myregistry/postgres:15-custom@sha256:abc123... securityContext: readOnlyRootFilesystem: true runAsNonRoot: true # Rest of security config remains unchanged volumeMounts: # Configuration is now part of the image; no ConfigMap needed - name: data mountPath: /var/lib/postgresql/data - name: tmp mountPath: /tmpSecurity preserved: Image is immutable at runtime, signed and verifiable, has no shell, and is reproducible (same inputs yield same image hash).
Example 2: Nginx with Custom TLS Certificates and Configuration
Setup:
mkdir nginx-custom/etc/nginxmkdir nginx-custom/etc/ssl/certsmkdir nginx-custom/etc/ssl/private # Copy TLS certs (these stay in the image, immutable at runtime)cp /path/to/server.crt nginx-custom/etc/ssl/certs/cp /path/to/server.key nginx-custom/etc/ssl/private/chmod 600 nginx-custom/etc/ssl/private/server.key # Copy custom nginx.confcp /path/to/nginx.conf nginx-custom/etc/nginx/Dockerfile:
FROM cleanstart/nginx:1.25-prod@sha256:abc123... # Copy TLS certsCOPY nginx-custom/etc/ssl/certs /etc/ssl/certs/COPY nginx-custom/etc/ssl/private /etc/ssl/private/ # Copy custom configCOPY nginx-custom/etc/nginx/nginx.conf /etc/nginx/nginx.conf # Set proper permissions (baked into image)RUN chmod 644 /etc/ssl/certs/* && \ chmod 600 /etc/ssl/private/* && \ chmod 644 /etc/nginx/nginx.conf # Image is still shell-less and read-only at runtimeRuntime in Kubernetes:
containers:- name: nginx image: myregistry/nginx:1.25-custom@sha256:abc123... securityContext: readOnlyRootFilesystem: true # Works because certs are in image allowPrivilegeEscalation: false capabilities: drop: ["ALL"] # No need to mount TLS certs (they're in the image) # If certs need to rotate, rebuild the imageExample 3: Python App with Custom Dependencies and Certificates
Setup:
mkdir app-custom # Add CA certificatesmkdir -p app-custom/etc/ssl/certscp corporate-ca.crt app-custom/etc/ssl/certs/ # Add custom pip index config (for private PyPI)mkdir -p app-custom/root/.pipcat > app-custom/root/.pip/pip.conf << 'EOF'[global]index-url = https://private-pypi.company.com/simple/cert = /etc/ssl/certs/corporate-ca.crtEOFDockerfile (multi-stage with cleanimg-customize-style overlay):
# Stage 1: Build stage (can use shell, not in final image)FROM cleanstart/python:3.11-dev@sha256:abc123... as builder WORKDIR /appCOPY requirements.txt . # Install dependenciesRUN pip install --no-cache-dir -r requirements.txt COPY . . # Stage 2: Runtime (final image, read-only, shell-less)FROM cleanstart/python:3.11-prod@sha256:xyz789... # Copy built packages from builderCOPY --from=builder /app /app # Copy CA certificatesCOPY app-custom/etc/ssl/certs/corporate-ca.crt /etc/ssl/certs/corporate-ca.crt WORKDIR /app # Final image is read-only and shell-lessVerify the layers:
docker inspect myregistry/python-app:1.0.0 | jq '.Layers | length'# Result: 3 layers# Layer 1: cleanstart/python:3.11-prod (base)# Layer 2: Copied /app and CA cert# Layer 3: WORKDIR and metadata # No shell in final image:docker run myregistry/python-app:1.0.0 /bin/sh# Error: exec: sh: not found ✅Approach 2: Multi-Stage Build (For Code Changes)
Multi-stage builds use a two-stage Dockerfile where the "dev" stage has tools, compilers, and shell (not deployed), while the "prod" stage uses CleanStart -prod image (read-only, shell-less). Only the production stage is deployed.
Use this approach when adding application code (Python, Node, Java, Go), compiling code (Go, Rust, C++), building artifacts (JAR files, Python wheels), or installing compiled dependencies. Build time is minutes (full pipeline), security is preserved (prod stage is read-only, shell-less; dev stage discarded).
Example 1: Go Application
Dockerfile:
# Stage 1: Build (discarded at runtime)FROM golang:1.21 as builder WORKDIR /srcCOPY . . # Build the binary (shell available in builder, not in final image)RUN CGO_ENABLED=0 GOOS=linux go build -o /out/app # Stage 2: Runtime (final image)FROM cleanstart/go:1.21-prod@sha256:abc123... # Copy only the compiled binary from builderCOPY --from=builder /out/app /app # Copy configCOPY config.yaml /etc/app/ # Final image contains only: Go runtime, CA certs, the app binary, cleanimg-init# No shell, no build tools, minimal attack surfaceBuild:
docker build -t myregistry/go-app:1.0.0 .docker push myregistry/go-app:1.0.0Runtime:
containers:- name: app image: myregistry/go-app:1.0.0@sha256:abc123... securityContext: readOnlyRootFilesystem: true runAsNonRoot: trueExample 2: Python Application with Native Dependencies
Dockerfile:
# Stage 1: Build (has gcc, python-dev, etc.)FROM python:3.11 as builder WORKDIR /srcCOPY requirements.txt . # Install in a venv (not in site-packages of final image)RUN python -m venv /opt/venvENV PATH="/opt/venv/bin:$PATH" # Install dependencies (including ones that need compilation)RUN pip install --no-cache-dir -r requirements.txt COPY . . # Stage 2: Runtime (minimal, read-only, shell-less)FROM cleanstart/python:3.11-prod@sha256:abc123... # Copy the virtualenv from builder (already compiled)COPY --from=builder /opt/venv /opt/venv # Copy application codeCOPY --from=builder /src /app # Copy CA certificates for TLSCOPY certs/corporate-ca.crt /etc/ssl/certs/corporate-ca.crt WORKDIR /appENV PATH="/opt/venv/bin:$PATH" # Final image is shell-less and read-onlyBuild and run:
docker build -t myregistry/python-app:1.0.0 . # Verify final image is minimal and has no shelldocker run --rm myregistry/python-app:1.0.0 /bin/sh# Error: exec: sh: not found ✅ # Verify CA cert is in imagedocker run --rm myregistry/python-app:1.0.0 cat /etc/ssl/certs/corporate-ca.crt# Shows certificate content ✅Example 3: Node.js Application
Dockerfile:
# Stage 1: BuildFROM node:20 as builder WORKDIR /appCOPY package*.json ./ # Install dependencies (includes devDependencies needed for build)RUN npm ci COPY . . # Build if needed (e.g., TypeScript compilation, bundling)RUN npm run build # Stage 2: RuntimeFROM cleanstart/node:20-prod@sha256:abc123... WORKDIR /app # Copy only production node_modulesCOPY --from=builder /app/package*.json ./RUN npm ci --only=production --no-audit # Copy built appCOPY --from=builder /app/build ./buildCOPY --from=builder /app/src ./src # Runtime environment (read-only at runtime)ENV NODE_ENV=production # Final image is shell-lessApproach 3: Kubernetes ConfigMaps/Secrets (For Runtime Config)
Kubernetes ConfigMaps and Secrets mount configuration as read-only volumes without image rebuild. Configuration is environment-specific (dev, staging, prod). Configuration is non-binary (no code, no binaries).
Use this approach when configuration changes per environment (not per image), secrets vary by deployment, or application settings need changes without code. Speed is instant (no build), security is preserved (mounted read-only, never part of image).
Do NOT use this approach for binary additions (use multi-stage build), certificate additions (use cleanimg-customize), or system-wide settings (use cleanimg-customize).
Example 1: Python App with Database Connection Config
ConfigMap:
apiVersion: v1kind: ConfigMapmetadata: name: app-config namespace: productiondata: app.env: | ENVIRONMENT=production LOG_LEVEL=info MAX_CONNECTIONS=100 CACHE_ENABLED=true---apiVersion: v1kind: Secretmetadata: name: app-secrets namespace: productiontype: OpaquestringData: database_url: "postgresql://user:password@postgres:5432/mydb" secret_key: "your-secret-key-here"Deployment:
apiVersion: apps/v1kind: Deploymentmetadata: name: python-appspec: template: spec: containers: - name: app image: myregistry/python-app:1.0.0@sha256:abc123... securityContext: readOnlyRootFilesystem: true volumeMounts: # Mount config as read-only - name: config mountPath: /etc/app readOnly: true # Mount secrets as read-only - name: secrets mountPath: /var/run/secrets/app readOnly: true env: # Reference from ConfigMap - name: LOG_LEVEL valueFrom: configMapKeyRef: name: app-config key: LOG_LEVEL # Reference from Secret - name: DATABASE_URL valueFrom: secretKeyRef: name: app-secrets key: database_url volumes: - name: config configMap: name: app-config - name: secrets secret: secretName: app-secretsApplication code reads config:
# app.pyimport osfrom pathlib import Path # From environment variables (most common)log_level = os.getenv('LOG_LEVEL', 'info')database_url = os.getenv('DATABASE_URL') # Or read from mounted filesconfig_path = Path('/etc/app/app.env')secrets_path = Path('/var/run/secrets/app/database_url') if config_path.exists(): with open(config_path) as f: for line in f: key, value = line.strip().split('=', 1) os.environ[key] = value if secrets_path.exists(): with open(secrets_path) as f: database_url = f.read().strip()Example 2: Nginx with Per-Environment Upstream Configuration
Staging ConfigMap:
apiVersion: v1kind: ConfigMapmetadata: name: nginx-config-stagingdata: nginx.conf: | http { upstream backend { server backend-staging:8000; } # ... rest of config }Production ConfigMap:
apiVersion: v1kind: ConfigMapmetadata: name: nginx-config-productiondata: nginx.conf: | http { upstream backend { server backend-prod-1:8000; server backend-prod-2:8000; server backend-prod-3:8000; } # ... rest of config }Deployment (uses staging config):
apiVersion: apps/v1kind: Deploymentmetadata: name: nginx namespace: stagingspec: template: spec: containers: - name: nginx image: myregistry/nginx:1.25-custom@sha256:abc123... securityContext: readOnlyRootFilesystem: true volumeMounts: - name: config mountPath: /etc/nginx/nginx.conf subPath: nginx.conf readOnly: true volumes: - name: config configMap: name: nginx-config-staging # Different per environmentDeployment (uses production config):
# Same deployment, different namespaceapiVersion: apps/v1kind: Deploymentmetadata: name: nginx namespace: productionspec: # ... same as above, except: volumes: - name: config configMap: name: nginx-config-production # Different per environmentBenefits: Image is identical in both environments, config differs but image hash remains the same, no rebuild for config changes, easier to test (roll out config changes without touching image).
Part 3: What BREAKS Security (Anti-Patterns)
Never do these, even if they seem convenient.
❌ Anti-Pattern 1: Adding /bin/sh "Just for Debugging"
Adding a shell interpreter to a CleanStart image—even temporarily for debugging—fundamentally breaks the security model. The Dockerfile might look innocent with a simple apt-get command to install bash during the build process. However, this single change catastrophically undermines the entire shell-less security architecture.
The consequences are severe. The entire shell-less security model collapses when a shell is present. The kubectl exec command, which normally fails because no shell exists, now works: kubectl exec postgres -- sh grants an attacker (or developer) an interactive shell inside the container. The attacker can now invoke arbitrary commands at runtime with whatever privileges the container runs with. The image is no longer production-ready—it's a development image that should never be deployed to production. Worse, if admission webhooks are configured to enforce shell-less containers (as security best practices recommend), they will reject deployment of this image, creating obstacles that encourage teams to disable the protection entirely.
The solution is to never add shells for debugging. Instead, use cleanimg-customize to add static debugging tools if necessary. Use multi-stage builds to add code. If you need interactive debugging, use a separate "debug" image for development environments only—never include debugging tools in production images. If you need to debug a production issue, the answer is to capture logs and diagnostic output, not to shell into production containers. The shell-less design forces you to think about observability and logging differently, which ultimately leads to better production operations.
❌ Anti-Pattern 2: Setting readOnlyRootFilesystem: false "Temporarily"
Disabling read-only filesystem enforcement in the Kubernetes securityContext—even temporarily—breaks the immutability contract. The YAML might specify readOnlyRootFilesystem: false with a comment "just for debugging" or "temporary workaround."
The consequences are severe and persistent. With writable filesystems, container drift emerges: the running instance diverges from the deployed image. Attackers can write to /tmp, /var, /root, and other locations to persist malware that survives pod restarts. Compliance frameworks—CIS Docker Benchmark, DISA STIG, FedRAMP—explicitly require read-only filesystems as a hard control. Disabling read-only violates these requirements, putting compliance audits at risk. Admission webhooks configured to enforce read-only filesystems will reject the pod, blocking deployment unless the security policy is disabled.
The solution is absolute: always use readOnlyRootFilesystem: true. If the application needs to write files, mount writable volumes (emptyDir or PersistentVolumeClaim) at specific paths. The application can write to /tmp via a tmpfs mount, but cannot write anywhere else. Use cleanimg-customize at build time if you need files baked into the image.
❌ Anti-Pattern 3: Running as Root
Running containers as the root user (UID 0) fundamentally undermines privilege isolation. The securityContext might specify runAsUser: 0 with the assumption that root privileges are needed.
This assumption is usually wrong. If an attacker gains code execution inside the container, they already have root privilege—there's nothing to escalate. The entire principle of least privilege is violated because the application runs with maximum privilege from the start. The CIS Docker Benchmark explicitly requires containers to be restricted from acquiring additional privileges (CIS 5.1), and running as root violates this principle. If a library or dependency has a vulnerability that leads to code execution, the attacker immediately has full control of the container and everything it can access.
The solution is to always run containers as non-root users. Use runAsNonRoot: true to enforce this at the platform level. Specify a specific non-root UID like 65532 (commonly used for unprivileged processes). CleanStart images pre-configure applications to run as appropriate non-root users, so this is typically just enforcing the existing configuration.
❌ Anti-Pattern 4: Using docker exec with Shell in Production
Manually exec'ing into production containers to modify configurations or debug issues indicates a broken security model. The command might look like kubectl exec -it postgres-0 -- /bin/bash, followed by manual file edits like vi /etc/postgresql/postgresql.conf.
This operational pattern is fundamentally broken for several reasons. If you're exec'ing into the container to modify configurations, the security model has failed—configurations should be immutable and managed through ConfigMaps or build-time customization. Changes made via exec are ephemeral, lost when the pod restarts. Changes are completely unaudited—no one can trace who changed what, when, and why, violating compliance requirements. Changes are irreproducible—you can't recreate the same container state because the changes were manual.
The correct approach is to never exec into production containers. Build-time customization (cleanimg-customize) applies configuration at image build time, making it reproducible and auditable. Runtime customization (ConfigMaps) applies configuration at deployment time via declarative YAML, visible in version control. If you need to debug a production issue, capture logs and diagnostic output rather than opening a shell. The shell-less design forces you toward better observability and logging practices, which ultimately leads to production systems you can actually understand and operate.
❌ Anti-Pattern 5: Disabling Seccomp "Because It Blocks Something"
Disabling Seccomp (secure computing mode) syscall filtering—the mechanism that restricts which system calls a container can invoke—introduces severe kernel-level security risks. The configuration might disable Seccomp entirely with the comment "this blocks something our app needs."
This decision exposes the container to kernel exploits. Seccomp blocks dangerous syscalls that normal applications never need: ptrace (process tracing, used for debuggers and intrusion), reboot (system reboot), mount (mounting filesystems), and others. These syscalls are only needed by special system tools, not by application containers. Without Seccomp filtering, if an attacker gains code execution and identifies a kernel vulnerability, they can invoke a dangerous syscall to escape the container, compromising the entire host. NIST 800-190 (Application Container Security Guide) explicitly requires Seccomp filtering as section 4.3. Disabling it is a compliance violation.
The solution is to keep the default Seccomp profile. Use seccompProfile.type: RuntimeDefault, which applies Kubernetes' standard Seccomp filter that blocks the most dangerous syscalls while allowing normal application syscalls. If your application genuinely needs a syscall that the default filter blocks, file an issue with CleanStart or work with them to adjust the profile. Using a custom Seccomp profile is a last resort, reserved for unusual situations.
❌ Anti-Pattern 6: Modifying Image Configuration at Runtime
Modifying container configuration at runtime—either by exec'ing in and running commands, or by updating environment variables on live deployments—breaks auditability and reproducibility. The commands might look innocent: running kubectl exec postgres-0 -- cleanimg-init -c /custom/config.toml, or using kubectl set env deployment/postgres DATABASE_PASSWORD=newpass.
This approach is broken for three reasons. First, runtime modifications are completely unaudited—no version control record, no commit history, no audit trail of who made the change or why. If something breaks, you have no way to investigate what changed. Second, modifications are lost when the pod restarts—they're ephemeral and unreproducible. Third, there's no visibility into the actual configuration running in production compared to what's declared in Git, creating drift and operational uncertainty.
The correct approach is to treat all configuration as declarative and auditable. Rebuild the image with configuration baked in at build time using cleanimg-customize. Use Kubernetes ConfigMap and Secret resources for environment-specific configuration, mounted at deployment time. All changes go through CI/CD pipelines, creating an audit trail, enabling rollback, and maintaining reproducibility. This approach ensures that what's in Git is exactly what's running in production.
Part 4: Decision Matrix: When to Use Which Approach
Customization Type | cleanimg-customize | Multi-Stage Build | ConfigMap/Secret |
|---|---|---|---|
CA Certificates | ✅ Recommended | ❌ Overkill | ❌ (should be in image) |
Application Code | ❌ Doesn't apply | ✅ Recommended | ❌ Not code |
Static Config | ✅ Works | ✅ Works | ✅ Better (env-specific) |
Secrets | ❌ (baked in image) | ❌ (baked in image) | ✅ Recommended |
Compiled Binaries | ✅ For pre-built | ✅ For building | ❌ Not binaries |
Python/JS Code | ❌ Doesn't apply | ✅ Recommended | ❌ Not code |
Database Schemas | ❌ (use migration) | ✅ Build migration binary | ❌ (use app logic) |
Documentation | ✅ If needed | ✅ Works | ❌ Won't be read |
Decision Tree
To choose the right customization approach, work through a systematic decision process that routes each customization to the appropriate pattern based on its characteristics and how it changes over time.
The first question is whether you're adding application code. If you're adding Python, Node, Go, Rust, or other application logic, use a multi-stage build. This pattern allows you to use a full development image in the build stage where compilers, build tools, and all development dependencies are available. You compile and build the application in that stage. Then you copy only the production artifacts—the compiled binary, the runtime dependencies, the application code—into the final CleanStart image. This approach produces a minimal, hardened runtime image because all build tools are discarded.
The second question is whether you're adding secrets or passwords. Never bake secrets into images. Secrets should always be managed by ConfigMap or Secret Kubernetes resources, mounted at runtime as volumes or environment variables. This approach keeps credentials out of image layers, prevents them from leaking in image registries, enables rotation without image rebuilds, and maintains clean separation between build-time (image) and runtime (secrets) concerns.
The third question is whether you're adding environment-specific configuration. Configuration that changes between development, staging, and production should be handled by ConfigMaps or Secrets mounted at runtime. This pattern allows you to build a single image and deploy it to all environments, with environment-specific configuration provided at runtime. Staging gets staging database endpoints; production gets production database endpoints. They're the same image, just with different configuration. This approach avoids the waste of rebuilding the entire image when only configuration changes.
The fourth question is whether you're adding static files that don't change per environment. For certificates, configuration files, utilities, and other static assets that are the same across all environments, use cleanimg-customize. This efficiently adds files to the image as a new OCI layer, keeping everything immutable, and maintains the complete security model—the image remains read-only, shell-less, and verifiable.
When in doubt, apply the fundamental principle: "Could this change without rebuilding the image?" If the answer is yes—configuration changes, secrets rotate, environment varies—then use ConfigMap or Secret. If the answer is no—this is static and permanent—then use cleanimg-customize or a multi-stage build depending on whether it's a static file (cleanimg-customize) or compiled code (multi-stage build).
Part 5: Complete Examples
Example 1: PostgreSQL with Custom Config and Certificates
Using cleanimg-customize:
# Create customization directorymkdir -p postgres-overlay/etc/ssl/certsmkdir -p postgres-overlay/etc/postgresql # Copy CA certificatecp corporate-ca.crt postgres-overlay/etc/ssl/certs/ # Create custom postgres configcat > postgres-overlay/etc/postgresql/custom.conf << 'EOF'# Custom PostgreSQL settingsmax_connections = 200shared_buffers = 512MBeffective_cache_size = 2GBwork_mem = 4MBlog_min_duration_statement = 1000log_statement = 'all'EOF # Apply customization using cleanimg-customize v0.3.0cat > postgres-spec.yaml <<EOFbase_image: "cleanstart/postgresql:15-prod@sha256:abc123def456..."arch: amd64variant: prodcopy_files: - source: postgres-overlay/etc/ssl/certs/corporate-ca.crt destination: /etc/ssl/certs/corporate-ca.crt - source: postgres-overlay/etc/postgresql/custom.conf destination: /etc/postgresql/custom.confEOFcleanimg-customize build --spec postgres-spec.yaml --tag myregistry/postgres:15-custom --push # Verify signaturecosign verify --key cosign.pub myregistry/postgres:15-customUsing Dockerfile (alternative):
FROM cleanstart/postgresql:15-prod@sha256:abc123def456... # Copy CA certificate (from build context)COPY corporate-ca.crt /etc/ssl/certs/corporate-ca.crt # Copy custom PostgreSQL configCOPY custom.conf /etc/postgresql/custom.conf # Ensure correct ownership (uid postgres = 65532)RUN chown 65532:65532 /etc/ssl/certs/corporate-ca.crt && \ chown 65532:65532 /etc/postgresql/custom.conf # Image remains read-only and shell-lessExample 2: Python Web App with Code and Dependencies
Dockerfile (multi-stage):
# Stage 1: Builder (development, shell available)FROM python:3.11 as builder # Install build dependenciesRUN apt-get update && apt-get install -y gcc libpq-dev WORKDIR /srcCOPY requirements.txt .COPY requirements-dev.txt . # Create virtualenv and install packagesRUN python -m venv /opt/venv && \ . /opt/venv/bin/activate && \ pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt # Copy and build applicationCOPY . .RUN . /opt/venv/bin/activate && python setup.py build # Stage 2: Runtime (production, read-only, shell-less)FROM cleanstart/python:3.11-prod@sha256:abc123def456... # Copy pre-built virtualenv from builderCOPY --from=builder /opt/venv /opt/venv # Copy application codeCOPY --from=builder /src /app # Copy CA certificateCOPY corporate-ca.crt /etc/ssl/certs/ WORKDIR /appENV PATH="/opt/venv/bin:$PATH" # Set read-only filesystem expectations# (Kubernetes will enforce this at runtime) # Final image contains only: Python runtime, app code, CA cert# No build tools, no shell, minimal attack surfaceKubernetes Deployment:
apiVersion: apps/v1kind: Deploymentmetadata: name: python-appspec: replicas: 3 template: spec: securityContext: runAsNonRoot: true runAsUser: 65532 fsGroup: 65532 readOnlyRootFilesystem: true allowPrivilegeEscalation: false capabilities: drop: ["ALL"] seccompProfile: type: RuntimeDefault containers: - name: app image: myregistry/python-app:1.0.0@sha256:abc123... volumeMounts: - name: tmp mountPath: /tmp - name: cache mountPath: /app/cache resources: requests: ephemeral-storage: 500Mi limits: ephemeral-storage: 2Gi volumes: - name: tmp emptyDir: sizeLimit: 1Gi - name: cache emptyDir: sizeLimit: 500MiExample 3: Nginx with Custom TLS Certificates and Per-Environment Config
Dockerfile:
FROM cleanstart/nginx:1.25-prod@sha256:abc123def456... # Copy TLS certificatesCOPY tls/server.crt /etc/ssl/certs/server.crtCOPY tls/server.key /etc/ssl/private/server.key # Copy corporate CACOPY tls/corporate-ca.crt /etc/ssl/certs/corporate-ca.crt # Set correct permissions (baked into image)RUN chmod 644 /etc/ssl/certs/*.crt && \ chmod 600 /etc/ssl/private/*.key # ConfigMap will provide environment-specific nginx.conf at runtimeConfigMaps for different environments:
# Staging environmentapiVersion: v1kind: ConfigMapmetadata: name: nginx-config namespace: stagingdata: nginx.conf: | upstream backend { server backend-staging:8000; } server { listen 8080; ssl_certificate /etc/ssl/certs/server.crt; ssl_certificate_key /etc/ssl/private/server.key; location / { proxy_pass http://backend; } }---# Production environment (different upstream, same image)apiVersion: v1kind: ConfigMapmetadata: name: nginx-config namespace: productiondata: nginx.conf: | upstream backend { server backend-prod-1:8000; server backend-prod-2:8000; server backend-prod-3:8000; } server { listen 8080; ssl_certificate /etc/ssl/certs/server.crt; ssl_certificate_key /etc/ssl/private/server.key; location / { proxy_pass http://backend; } }Deployment (same across both environments):
apiVersion: apps/v1kind: Deploymentmetadata: name: nginxspec: replicas: 3 template: spec: securityContext: readOnlyRootFilesystem: true runAsNonRoot: true containers: - name: nginx image: myregistry/nginx:1.25-custom@sha256:abc123... volumeMounts: # TLS certs are in the image (immutable) # nginx.conf comes from ConfigMap (environment-specific) - name: config mountPath: /etc/nginx/nginx.conf subPath: nginx.conf readOnly: true volumes: - name: config configMap: name: nginx-config # Different per environmentSummary: The Customization Matrix
Approach | Speed | Reproducibility | Audit Trail | Use When |
|---|---|---|---|---|
cleanimg-customize | Seconds | ✅ Excellent | ✅ Easy to track | Adding files, certs, configs to every deployment |
Multi-stage build | Minutes | ✅ Excellent | ✅ In Dockerfile | Adding code, compiling, building artifacts |
ConfigMap/Secret | Instant | ✅ Excellent | ✅ Via kubectl | Config that changes per environment |
All three approaches preserve the read-only, shell-less security model at runtime. Choose based on what you're adding and how often it changes: use cleanimg-customize for static additions every deployment, use multi-stage build for code or compilation, and use ConfigMap/Secret for per-environment config.
