Skip to content

Commit 5d644bd

Browse files
authored
Merge pull request #292 from DevKor-github/codexd/harden-docker-deploy-280
Harden Docker image and deployment configuration
2 parents dc73fed + 930c8db commit 5d644bd

10 files changed

Lines changed: 434 additions & 166 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 147 additions & 131 deletions
Large diffs are not rendered by default.

docs/deployment.md

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# Production Deployment
2+
3+
This service deploys as an immutable Docker image published to GitHub Container Registry (GHCR). Runtime configuration is injected through the EC2 `.env` file generated by GitHub Actions; private resource files are not copied into the image or bind-mounted from the host.
4+
5+
## Required GitHub Secrets
6+
7+
Deployment access:
8+
9+
- `EC2_HOST`
10+
- `EC2_USER`
11+
- `EC2_SSH_KEY`
12+
- `GHCR_USERNAME`
13+
- `GHCR_READ_TOKEN`
14+
15+
Runtime image and port:
16+
17+
- `BACKEND_HTTP_PORT` (optional, defaults to `8080`)
18+
19+
Spring and database:
20+
21+
- `SPRING_APPLICATION_NAME`
22+
- `SPRING_DATASOURCE_URL`
23+
- `SPRING_DATASOURCE_USERNAME`
24+
- `SPRING_DATASOURCE_PASSWORD`
25+
- `SPRING_DATASOURCE_DRIVER_CLASS_NAME`
26+
- `SPRING_JPA_HIBERNATE_DDL_AUTO`
27+
- `SPRING_FLYWAY_URL`
28+
- `SPRING_FLYWAY_USER`
29+
- `SPRING_FLYWAY_PASSWORD`
30+
31+
Authentication and OAuth:
32+
33+
- `JWT_SECRETKEY`
34+
- `JWT_ACCESS_EXPIRATION`
35+
- `JWT_REFRESH_EXPIRATION`
36+
- `JWT_ACCESS_HEADER`
37+
- `JWT_REFRESH_HEADER`
38+
- `GOOGLE_WEB_CLIENT_ID`
39+
- `GOOGLE_APP_CLIENT_ID`
40+
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_SECRET`
41+
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_SCOPE`
42+
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_REDIRECT_URI`
43+
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_AUTHORIZATION_GRANT_TYPE`
44+
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_NAME`
45+
- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_AUTHORIZATION_URI`
46+
- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_TOKEN_URI`
47+
- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_USER_INFO_URI`
48+
- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_USER_NAME_ATTRIBUTE`
49+
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_ID`
50+
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_SCOPE`
51+
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_REDIRECT_URI`
52+
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_AUTHORIZATION_GRANT_TYPE`
53+
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_NAME`
54+
- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_AUTHORIZATION_URI`
55+
- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_TOKEN_URI`
56+
- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_USER_INFO_URI`
57+
- `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_USER_NAME_ATTRIBUTE`
58+
- `APPLE_CLIENT_ID`
59+
- `APPLE_TEAM_ID`
60+
- `APPLE_LOGIN_KEY`
61+
- `APPLE_PRIVATE_KEY_BASE64`
62+
- `FEATURE_APPLE_LOGIN_ENABLED` (optional, defaults to `true`)
63+
64+
Firebase:
65+
66+
- `FIREBASE_CREDENTIALS_BASE64`
67+
68+
Set the base64 secrets from the ignored local credential files:
69+
70+
```bash
71+
base64 -i ontime-back/src/main/resources/ontime-c63f1-firebase-adminsdk-fbsvc-a043cdc829.json | tr -d '\n' | gh secret set FIREBASE_CREDENTIALS_BASE64 --repo DevKor-github/OnTime-back
72+
```
73+
74+
```bash
75+
base64 -i ontime-back/src/main/resources/key/AuthKey_743M7R5W3W.p8 | tr -d '\n' | gh secret set APPLE_PRIVATE_KEY_BASE64 --repo DevKor-github/OnTime-back
76+
```
77+
78+
## Build And Release Flow
79+
80+
Push to the `deploy` branch to trigger `.github/workflows/deploy.yml`.
81+
82+
The workflow:
83+
84+
1. Builds `ontime-back/Dockerfile` from the `ontime-back/` context.
85+
2. Pushes two GHCR tags:
86+
- `ghcr.io/devkor-github/ontime-back:<commit-sha>`
87+
- `ghcr.io/devkor-github/ontime-back:deploy-latest`
88+
3. Uploads `docker-compose.yml` to `/home/ubuntu/OnTime-back`.
89+
4. Writes `/home/ubuntu/OnTime-back/.env` from GitHub secrets.
90+
5. Runs `docker compose pull && docker compose up -d --remove-orphans`.
91+
6. Waits until the `ontime-container` Docker health status is `healthy`.
92+
93+
## Health Verification
94+
95+
The production image exposes a Docker healthcheck against:
96+
97+
```text
98+
/actuator/health/readiness
99+
```
100+
101+
Manual checks on EC2:
102+
103+
```bash
104+
cd /home/ubuntu/OnTime-back
105+
sudo docker compose ps
106+
sudo docker inspect -f '{{.State.Health.Status}}' ontime-container
107+
curl -fsS http://localhost:8080/actuator/health/readiness
108+
```
109+
110+
## Rollback
111+
112+
Every deploy is tagged by commit SHA. To roll back, set `IMAGE_TAG` in `/home/ubuntu/OnTime-back/.env` to the previous known-good SHA, then restart from the existing Compose file:
113+
114+
```bash
115+
cd /home/ubuntu/OnTime-back
116+
sudo docker compose pull
117+
sudo docker compose up -d --remove-orphans
118+
sudo docker inspect -f '{{.State.Health.Status}}' ontime-container
119+
```
120+
121+
Keep the previous SHA in the release notes or GitHub Actions deploy history so rollback does not depend on `deploy-latest`.

ontime-back/.dockerignore

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
.env
2+
.git
3+
.gitignore
4+
.gradle
5+
build
6+
out
7+
bin
8+
.idea
9+
.vscode
10+
*.iml
11+
*.iws
12+
*.ipr
13+
.DS_Store
14+
15+
src/main/resources/application.properties
16+
src/main/resources/*.json
17+
src/main/resources/key
18+
src/main/resources/**/*.p8
19+
20+
Dockerfile
21+
docker-compose*.yml

ontime-back/Dockerfile

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,40 @@
1-
FROM eclipse-temurin:17-jdk
2-
RUN apt-get update && \
3-
apt-get install -y tzdata && \
4-
ln -snf /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \
5-
echo "Asia/Seoul" > /etc/timezone && \
6-
apt-get clean && \
7-
rm -rf /var/lib/apt/lists/*
1+
FROM eclipse-temurin:17-jdk-jammy AS builder
2+
3+
WORKDIR /workspace
4+
5+
COPY gradlew build.gradle settings.gradle ./
6+
COPY gradle ./gradle
7+
RUN chmod +x ./gradlew
8+
9+
COPY src ./src
10+
RUN ./gradlew clean bootJar --no-daemon
11+
12+
FROM eclipse-temurin:17-jre-jammy
13+
14+
ENV TZ=Asia/Seoul \
15+
LANG=C.UTF-8 \
16+
SPRING_PROFILES_ACTIVE=prod \
17+
JAVA_TOOL_OPTIONS="-XX:InitialRAMPercentage=50.0 -XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/./urandom"
18+
19+
RUN apt-get update \
20+
&& apt-get install -y --no-install-recommends curl tzdata \
21+
&& ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
22+
&& echo "${TZ}" > /etc/timezone \
23+
&& groupadd --system app \
24+
&& useradd --system --gid app --home-dir /app --shell /usr/sbin/nologin app \
25+
&& mkdir -p /app \
26+
&& chown -R app:app /app \
27+
&& apt-get clean \
28+
&& rm -rf /var/lib/apt/lists/*
29+
830
WORKDIR /app
9-
COPY project.jar app.jar
10-
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
31+
COPY --from=builder --chown=app:app /workspace/build/libs/*.jar /app/app.jar
32+
33+
USER app
34+
EXPOSE 8080
35+
STOPSIGNAL SIGTERM
36+
37+
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
38+
CMD curl -fsS "http://localhost:${SERVER_PORT:-8080}/actuator/health/readiness" || exit 1
39+
40+
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

ontime-back/docker-compose.yml

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
1-
version: "3.8"
2-
31
services:
42
backend:
5-
build:
6-
context: .
7-
dockerfile: Dockerfile # Dockerfile 이름
8-
image: ontimedemo # 빌드된 백엔드 이미지
9-
container_name: ontime-container
3+
image: "${BACKEND_IMAGE:-ghcr.io/devkor-github/ontime-back}:${IMAGE_TAG:?IMAGE_TAG is required}"
4+
container_name: "${BACKEND_CONTAINER_NAME:-ontime-container}"
5+
env_file:
6+
- .env
7+
environment:
8+
SPRING_PROFILES_ACTIVE: "${SPRING_PROFILES_ACTIVE:-prod}"
9+
SERVER_PORT: "${SERVER_PORT:-8080}"
10+
JAVA_TOOL_OPTIONS: "${JAVA_TOOL_OPTIONS:--XX:InitialRAMPercentage=50.0 -XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/./urandom}"
1011
ports:
11-
- "8080:8080"
12-
- "8443:8443"
13-
volumes:
14-
- /home/ubuntu/OnTime-back/ontime-back/src/main/resources/:/app/src/main/resources/
15-
- /home/ubuntu/OnTime-back/ontime-back/src/main/resources/key/:/app/resources/key/
12+
- "${BACKEND_HTTP_PORT:-8080}:${SERVER_PORT:-8080}"
13+
restart: unless-stopped
14+
stop_grace_period: 30s
15+
mem_limit: "${BACKEND_MEMORY_LIMIT:-768m}"
16+
cpus: "${BACKEND_CPU_LIMIT:-1.0}"
17+
healthcheck:
18+
test: ["CMD-SHELL", "curl -fsS http://localhost:${SERVER_PORT:-8080}/actuator/health/readiness || exit 1"]
19+
interval: 30s
20+
timeout: 5s
21+
start_period: 60s
22+
retries: 3

ontime-back/src/main/java/devkor/ontime_back/config/FirebaseInitialization.java

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,79 @@
44
import com.google.firebase.FirebaseApp;
55
import com.google.firebase.FirebaseOptions;
66
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.beans.factory.annotation.Value;
78
import org.springframework.stereotype.Service;
89

910
import javax.annotation.PostConstruct;
10-
import java.io.FileInputStream;
11-
import java.io.FileNotFoundException;
11+
import java.io.ByteArrayInputStream;
1212
import java.io.IOException;
1313
import java.io.InputStream;
14+
import java.nio.charset.StandardCharsets;
15+
import java.nio.file.Files;
16+
import java.nio.file.Path;
17+
import java.util.Base64;
1418

1519
@Service
20+
@Slf4j
1621
public class FirebaseInitialization {
1722

23+
@Value("${firebase.credentials.base64:}")
24+
private String firebaseCredentialsBase64;
25+
26+
@Value("${firebase.credentials.json:}")
27+
private String firebaseCredentialsJson;
28+
29+
@Value("${firebase.credentials.path:}")
30+
private String firebaseCredentialsPath;
31+
32+
@Value("${google.application.credentials:}")
33+
private String googleApplicationCredentials;
34+
1835
@PostConstruct
1936
public void initialize() {
2037
try {
21-
InputStream serviceAccount = getClass().getClassLoader().getResourceAsStream("ontime-c63f1-firebase-adminsdk-fbsvc-a043cdc829.json");
22-
if (serviceAccount == null) {
23-
throw new FileNotFoundException("Resource not found: ontime-c63f1-firebase-adminsdk-fbsvc-a043cdc829.json");
38+
if (!FirebaseApp.getApps().isEmpty()) {
39+
return;
2440
}
2541

26-
FirebaseOptions options = new FirebaseOptions.Builder()
27-
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
28-
.build();
42+
try (InputStream serviceAccount = resolveCredentials()) {
43+
if (serviceAccount == null) {
44+
log.warn("Firebase credentials were not provided; Firebase push notifications are disabled.");
45+
return;
46+
}
2947

30-
FirebaseApp.initializeApp(options);
48+
FirebaseOptions options = new FirebaseOptions.Builder()
49+
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
50+
.build();
51+
52+
FirebaseApp.initializeApp(options);
53+
}
3154
} catch (IOException e) {
32-
e.printStackTrace();
55+
log.error("Failed to initialize Firebase.", e);
56+
}
57+
}
58+
59+
private InputStream resolveCredentials() throws IOException {
60+
if (hasText(firebaseCredentialsBase64)) {
61+
byte[] decodedCredentials = Base64.getDecoder().decode(firebaseCredentialsBase64);
62+
return new ByteArrayInputStream(decodedCredentials);
63+
}
64+
65+
if (hasText(firebaseCredentialsJson)) {
66+
return new ByteArrayInputStream(firebaseCredentialsJson.getBytes(StandardCharsets.UTF_8));
67+
}
68+
69+
String credentialsPath = hasText(firebaseCredentialsPath)
70+
? firebaseCredentialsPath
71+
: googleApplicationCredentials;
72+
if (hasText(credentialsPath)) {
73+
return Files.newInputStream(Path.of(credentialsPath));
3374
}
75+
76+
return null;
77+
}
78+
79+
private boolean hasText(String value) {
80+
return value != null && !value.isBlank();
3481
}
3582
}

ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
7171
.frameOptions(frameOptions -> frameOptions.disable()))
7272
.authorizeHttpRequests(auth -> auth
7373
.requestMatchers("/", "/css/**", "/images/**", "/js/**", "/favicon.ico", "/h2-console/**").permitAll()
74-
.requestMatchers("/health", "/oauth2/sign-up", "oauth2/success", "login/success", "/oauth2/google/login", "/oauth2/kakao/login", "/oauth2/apple/login", "/sign-up", "/*/additional-info").permitAll()
74+
.requestMatchers("/health", "/actuator/health/**", "/oauth2/sign-up", "oauth2/success", "login/success", "/oauth2/google/login", "/oauth2/kakao/login", "/oauth2/apple/login", "/sign-up", "/*/additional-info").permitAll()
7575
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**", "/webjars/**", "/swagger-ui.html").permitAll()
7676
.requestMatchers("/error").permitAll()
7777
.requestMatchers("/health").permitAll() // 로드밸런서 연결 확인용 url

ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilter.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
@Slf4j
3333
public class JwtAuthenticationFilter extends OncePerRequestFilter {
3434

35-
private static final List<String> NO_CHECK_URLS = List.of("/login", "/swagger-ui", "/sign-up", "/v3/api-docs", "/oauth2/google/login", "/oauth2/kakao/login", "/oauth2/apple/login");
35+
private static final List<String> NO_CHECK_URLS = List.of("/login", "/health", "/actuator/health", "/swagger-ui", "/sign-up", "/v3/api-docs", "/oauth2/google/login", "/oauth2/kakao/login", "/oauth2/apple/login");
3636

3737
private final JwtTokenProvider jwtTokenProvider;
3838
private final UserRepository userRepository;

ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import org.springframework.web.util.UriComponentsBuilder;
3535

3636
import java.io.IOException;
37+
import java.nio.charset.StandardCharsets;
3738
import java.nio.file.Files;
3839
import java.nio.file.Paths;
3940
import java.security.KeyFactory;
@@ -58,8 +59,12 @@ public class AppleLoginService {
5859
private String teamId;
5960
@Value("${apple.login.key}")
6061
private String keyId;
61-
@Value("${apple.client.secret}")
62+
@Value("${apple.client.secret:}")
6263
private String privateKeyPath;
64+
@Value("${apple.private-key.base64:}")
65+
private String privateKeyBase64;
66+
@Value("${apple.private-key:}")
67+
private String privateKey;
6368

6469
private final ApplePublicKeyGenerator applePublicKeyGenerator;
6570
private final JwtUtils jwtUtils;
@@ -218,7 +223,7 @@ public AppleTokenResponseDto getAppleAccessTokenAndRefreshToken(String authCode)
218223
private String generateClientSecret() throws Exception {
219224
log.info("Generate Apple client credential");
220225
// Private Key
221-
String privateKeyContent = new String(Files.readAllBytes(Paths.get(privateKeyPath)))
226+
String privateKeyContent = resolvePrivateKey()
222227
.replace("-----BEGIN PRIVATE KEY-----", "")
223228
.replace("-----END PRIVATE KEY-----", "")
224229
.replaceAll("\\s", "");
@@ -243,6 +248,20 @@ private String generateClientSecret() throws Exception {
243248
.signWith(privateKey, SignatureAlgorithm.ES256)
244249
.compact();
245250
}
251+
252+
private String resolvePrivateKey() throws IOException {
253+
if (privateKeyBase64 != null && !privateKeyBase64.isBlank()) {
254+
byte[] decodedPrivateKey = Base64.getDecoder().decode(privateKeyBase64);
255+
return new String(decodedPrivateKey, StandardCharsets.UTF_8);
256+
}
257+
258+
if (privateKey != null && !privateKey.isBlank()) {
259+
return privateKey;
260+
}
261+
262+
return new String(Files.readAllBytes(Paths.get(privateKeyPath)));
263+
}
264+
246265
public boolean revokeToken(Long userId) throws Exception {
247266
log.info("checkAppleLoginRevoked");
248267
User user = userRepository.findById(userId)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
management.endpoint.health.probes.enabled=true
2+
management.endpoints.web.exposure.include=health
3+
management.health.readinessstate.enabled=true
4+
management.health.livenessstate.enabled=true
5+
server.shutdown=graceful
6+
spring.lifecycle.timeout-per-shutdown-phase=30s
7+
server.forward-headers-strategy=framework

0 commit comments

Comments
 (0)