Skip to content

Commit fc99d0e

Browse files
bpamiriclaude
andcommitted
fix: close deployment security gaps — secrets, TLS, auto-migrate, image pinning (wd-6ve)
1. Secrets out of Docker image: add .dockerignore excluding .env, remove .env generation from build job, inject secrets at runtime via compose environment + envsubst, add OS env var fallback in Application.cfc so the app reads from Docker env when no .env file exists. 2. Portainer TLS verification: remove -k flag from all curl calls so MITM attacks against the API key are no longer silently accepted. 3. autoMigrateDatabase=false in production config to prevent uncontrolled schema changes on app startup. 4. Pin Docker base image via build ARG with digest resolution in CI; remove :latest tag, deploy SHA-tagged images for deterministic deploys. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 85691e2 commit fc99d0e

File tree

6 files changed

+110
-65
lines changed

6 files changed

+110
-65
lines changed

.dockerignore

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Secrets — NEVER include in image
2+
.env
3+
.env.*
4+
!.env.example
5+
6+
# Git and CI
7+
.git
8+
.github
9+
.gitignore
10+
11+
# IDE and tooling
12+
.vscode
13+
.project
14+
.settings
15+
.classpath
16+
.CommandBox
17+
.claude
18+
.beads
19+
.runtime
20+
21+
# Documentation
22+
*.md
23+
24+
# Tests
25+
tests/
26+
27+
# Deploy configs (not needed in image)
28+
deploy/prod/
29+
deploy/stage/

.github/workflows/swarm-deploy.yml

Lines changed: 35 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -18,60 +18,12 @@ jobs:
1818
- name: Checkout repository
1919
uses: actions/checkout@v4
2020

21-
- name: Generate .env from secrets
22-
env:
23-
RELOAD_PASSWORD: ${{ secrets.RELOAD_PASSWORD }}
24-
CFCONFIG_ADMIN_PASSWORD: ${{ secrets.CFCONFIG_ADMIN_PASSWORD }}
25-
WHEELSDEV_HOST: ${{ secrets.WHEELSDEV_HOST }}
26-
WHEELSDEV_PORT: ${{ secrets.WHEELSDEV_PORT }}
27-
WHEELSDEV_DATABASENAME: ${{ secrets.WHEELSDEV_DATABASENAME }}
28-
WHEELSDEV_USERNAME: ${{ secrets.WHEELSDEV_USERNAME }}
29-
WHEELSDEV_PASSWORD: ${{ secrets.WHEELSDEV_PASSWORD }}
30-
SMTP_USERNAME: ${{ secrets.SMTP_USERNAME }}
31-
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
32-
WHEELS_ID_SALT: ${{ secrets.WHEELS_ID_SALT }}
33-
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
21+
- name: Resolve base image digest for reproducible builds
3422
run: |
35-
cat > .env << ENVEOF
36-
LUCEE_EXTENSIONS=BEC20D47-3268-1B354-C0E8E70B5CBC15A1;name=PostgreSQL;version=42.7.4
37-
38-
environment=production
39-
reloadPassword=${RELOAD_PASSWORD}
40-
41-
cfconfig_adminPassword=${CFCONFIG_ADMIN_PASSWORD}
42-
43-
application_host=https://wheels.dev
44-
datasource=wheels.dev
45-
46-
wheelsdev_host=${WHEELSDEV_HOST}
47-
wheelsdev_port=${WHEELSDEV_PORT}
48-
wheelsdev_databasename=${WHEELSDEV_DATABASENAME}
49-
wheelsdev_username=${WHEELSDEV_USERNAME}
50-
wheelsdev_password=${WHEELSDEV_PASSWORD}
51-
wheelsdev_clob=true
52-
wheelsdev_connectionlimit=100
53-
wheelsdev_storage=true
54-
55-
wheelsdev_storage=true
56-
sessionStorage=wheels.dev
57-
sessionCluster=true
58-
59-
test_case=false
60-
mail_from=noreply@wheels.dev
61-
62-
smtp_host=smtp.postmarkapp.com
63-
smtp_port=587
64-
smtp_username=${SMTP_USERNAME}
65-
smtp_password=${SMTP_PASSWORD}
66-
smtp_ssl=false
67-
smtp_tls=true
68-
69-
wheels_id_salt=${WHEELS_ID_SALT}
70-
71-
SENTRY_DSN=${SENTRY_DSN}
72-
SENTRY_ENVIRONMENT=production
73-
ENVEOF
74-
sed -i 's/^ //' .env
23+
docker pull ortussolutions/commandbox:lucee6
24+
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' ortussolutions/commandbox:lucee6)
25+
echo "COMMANDBOX_DIGEST=${DIGEST}" >> "$GITHUB_ENV"
26+
echo "Pinned base image: ${DIGEST}"
7527
7628
- name: Copy swarm Dockerfile to root
7729
run: |
@@ -103,8 +55,9 @@ jobs:
10355
context: .
10456
file: ./dockerfile
10557
push: true
58+
build-args: |
59+
COMMANDBOX_IMAGE=${{ env.COMMANDBOX_DIGEST }}
10660
tags: |
107-
${{ env.IMAGE_NAME }}:latest
10861
${{ env.IMAGE_NAME }}:${{ github.sha }}
10962
cache-from: type=gha
11063
cache-to: type=gha,mode=max
@@ -128,31 +81,50 @@ jobs:
12881
- name: Set task history limit
12982
run: docker swarm update --task-history-limit 2
13083

84+
- name: Resolve compose file with secrets
85+
env:
86+
IMAGE_TAG: ${{ github.sha }}
87+
RELOAD_PASSWORD: ${{ secrets.RELOAD_PASSWORD }}
88+
CFCONFIG_ADMIN_PASSWORD: ${{ secrets.CFCONFIG_ADMIN_PASSWORD }}
89+
WHEELSDEV_HOST: ${{ secrets.WHEELSDEV_HOST }}
90+
WHEELSDEV_PORT: ${{ secrets.WHEELSDEV_PORT }}
91+
WHEELSDEV_DATABASENAME: ${{ secrets.WHEELSDEV_DATABASENAME }}
92+
WHEELSDEV_USERNAME: ${{ secrets.WHEELSDEV_USERNAME }}
93+
WHEELSDEV_PASSWORD: ${{ secrets.WHEELSDEV_PASSWORD }}
94+
SMTP_USERNAME: ${{ secrets.SMTP_USERNAME }}
95+
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
96+
WHEELS_ID_SALT: ${{ secrets.WHEELS_ID_SALT }}
97+
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
98+
run: |
99+
envsubst '$IMAGE_TAG $RELOAD_PASSWORD $CFCONFIG_ADMIN_PASSWORD $WHEELSDEV_HOST $WHEELSDEV_PORT $WHEELSDEV_DATABASENAME $WHEELSDEV_USERNAME $WHEELSDEV_PASSWORD $SMTP_USERNAME $SMTP_PASSWORD $WHEELS_ID_SALT $SENTRY_DSN' \
100+
< deploy/swarm/docker-compose.yml \
101+
> /tmp/docker-compose-resolved.yml
102+
131103
- name: Deploy stack via Portainer API
132104
env:
133105
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
134106
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
135107
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }}
136108
run: |
137-
COMPOSE_CONTENT=$(cat deploy/swarm/docker-compose.yml)
109+
COMPOSE_CONTENT=$(cat /tmp/docker-compose-resolved.yml)
138110
139111
# Get the swarm ID from Portainer
140-
SWARM_ID=$(curl -sfk \
112+
SWARM_ID=$(curl -sf \
141113
-H "X-API-Key: ${PORTAINER_API_KEY}" \
142114
"${PORTAINER_URL}/api/endpoints/${PORTAINER_ENDPOINT_ID}/docker/swarm" \
143115
| jq -r '.ID')
144116
echo "Swarm ID: ${SWARM_ID}"
145117
146118
# Check if the stack already exists
147-
STACK_INFO=$(curl -sfk \
119+
STACK_INFO=$(curl -sf \
148120
-H "X-API-Key: ${PORTAINER_API_KEY}" \
149121
"${PORTAINER_URL}/api/stacks" \
150122
| jq -r '.[] | select(.Name == "wheels-dev")')
151123
152124
if [ -n "$STACK_INFO" ]; then
153125
STACK_ID=$(echo "$STACK_INFO" | jq -r '.Id')
154126
echo "Updating existing stack (ID: ${STACK_ID})..."
155-
HTTP_CODE=$(curl -sk -o /tmp/portainer-response.json -w "%{http_code}" \
127+
HTTP_CODE=$(curl -s -o /tmp/portainer-response.json -w "%{http_code}" \
156128
-X PUT \
157129
-H "X-API-Key: ${PORTAINER_API_KEY}" \
158130
-H "Content-Type: application/json" \
@@ -163,7 +135,7 @@ jobs:
163135
}')")
164136
else
165137
echo "Creating new stack..."
166-
HTTP_CODE=$(curl -sk -o /tmp/portainer-response.json -w "%{http_code}" \
138+
HTTP_CODE=$(curl -s -o /tmp/portainer-response.json -w "%{http_code}" \
167139
-X POST \
168140
-H "X-API-Key: ${PORTAINER_API_KEY}" \
169141
-H "Content-Type: application/json" \
@@ -183,12 +155,14 @@ jobs:
183155
fi
184156
echo "Stack deployed successfully via Portainer"
185157
186-
- name: Force pull latest image
158+
- name: Update service to SHA-tagged image
159+
env:
160+
IMAGE_TAG: ${{ github.sha }}
187161
run: |
188162
for i in 1 2 3 4 5; do
189163
docker service update \
190164
--with-registry-auth \
191-
--image ghcr.io/wheels-dev/wheels-dev:latest \
165+
--image "ghcr.io/wheels-dev/wheels-dev:${IMAGE_TAG}" \
192166
--force \
193167
wheels-dev_wheels-dev && break
194168
echo "Attempt $i failed (update out of sequence), retrying in 15s..."

config/production/settings.cfm

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
// Use this file to configure specific settings for the "production" environment.
33
// A variable set in this file will override the one in "app/config/settings.cfm".
44
5-
// Setup automatic migrations
6-
set(autoMigrateDatabase=true);
5+
// Disable auto-migration in production — run migrations explicitly in deploy pipeline
6+
set(autoMigrateDatabase=false);
77
88
// Example:
99
// set(errorEmailAddress="someone@somewhere.com");

deploy/swarm/docker-compose.yml

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
services:
22
wheels-dev:
3-
image: ghcr.io/wheels-dev/wheels-dev:latest
3+
image: ghcr.io/wheels-dev/wheels-dev:${IMAGE_TAG}
44
deploy:
55
replicas: 3
66
restart_policy:
@@ -44,6 +44,34 @@ services:
4444
PORT: 8888
4545
HEALTHCHECK_URI: "http://127.0.0.1:8888/"
4646
BOX_SERVER_JVM_ARGS: "-Xms2g -Xmx3g"
47+
# App configuration (non-secret)
48+
environment: production
49+
application_host: "https://wheels.dev"
50+
datasource: "wheels.dev"
51+
wheelsdev_clob: "true"
52+
wheelsdev_connectionlimit: "100"
53+
wheelsdev_storage: "true"
54+
sessionStorage: "wheels.dev"
55+
sessionCluster: "true"
56+
test_case: "false"
57+
mail_from: "noreply@wheels.dev"
58+
smtp_host: "smtp.postmarkapp.com"
59+
smtp_port: "587"
60+
smtp_ssl: "false"
61+
smtp_tls: "true"
62+
SENTRY_ENVIRONMENT: production
63+
# Secrets — resolved by envsubst at deploy time
64+
reloadPassword: "${RELOAD_PASSWORD}"
65+
cfconfig_adminPassword: "${CFCONFIG_ADMIN_PASSWORD}"
66+
wheelsdev_host: "${WHEELSDEV_HOST}"
67+
wheelsdev_port: "${WHEELSDEV_PORT}"
68+
wheelsdev_databasename: "${WHEELSDEV_DATABASENAME}"
69+
wheelsdev_username: "${WHEELSDEV_USERNAME}"
70+
wheelsdev_password: "${WHEELSDEV_PASSWORD}"
71+
smtp_username: "${SMTP_USERNAME}"
72+
smtp_password: "${SMTP_PASSWORD}"
73+
wheels_id_salt: "${WHEELS_ID_SALT}"
74+
SENTRY_DSN: "${SENTRY_DSN}"
4775
healthcheck:
4876
test: ["CMD", "curl", "-f", "http://127.0.0.1:8888/"]
4977
interval: 30s

deploy/swarm/dockerfile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
FROM ortussolutions/commandbox:lucee6
1+
# Pin base image — pass --build-arg to override with digest for reproducible builds
2+
# Resolve digest: docker pull ortussolutions/commandbox:lucee6 && docker inspect --format='{{index .RepoDigests 0}}' ortussolutions/commandbox:lucee6
3+
ARG COMMANDBOX_IMAGE=ortussolutions/commandbox:lucee6
4+
FROM ${COMMANDBOX_IMAGE}
25

36
# Install PostgreSQL JDBC extension for CockroachDB connectivity
47
ENV LUCEE_EXTENSIONS="BEC20D47-3268-1B354-C0E8E70B5CBC15A1;name=PostgreSQL;version=42.7.4"

public/Application.cfc

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,17 @@ component output="false" {
8686
}
8787
}
8888

89+
// Fall back to OS environment variables for keys not set by .env files
90+
// This enables Docker Swarm deployments to inject config via compose environment:
91+
try {
92+
local.osEnv = createObject("java", "java.lang.System").getenv();
93+
for (local.envKey in local.osEnv) {
94+
if (!structKeyExists(this.env, local.envKey)) {
95+
this.env[local.envKey] = local.osEnv[local.envKey];
96+
}
97+
}
98+
} catch (any e) {}
99+
89100
// Perform variable interpolation
90101
performVariableInterpolation(this.env);
91102
}

0 commit comments

Comments
 (0)