From 356419b2b045ee652bde3cd96ed0d0a1b0981248 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Thu, 11 Jun 2026 23:14:25 +0400 Subject: [PATCH 1/3] docs(lab1,lab2): clarify GitHub auth vs signing SSH key roles; add publickey-denied pitfalls Signed-off-by: Dmitrii Creed --- labs/lab1.md | 20 ++++++++++++++++---- labs/lab2.md | 1 + 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/labs/lab1.md b/labs/lab1.md index bb4e226d9..eb319e50f 100644 --- a/labs/lab1.md +++ b/labs/lab1.md @@ -91,9 +91,20 @@ git config --global commit.gpgsign true git config --global tag.gpgsign true ``` -Tell the platform your SSH key is a **signing key**: -- GitHub: Settings → SSH and GPG keys → **New SSH key**, key type **Signing Key** -- GitLab: Profile → SSH Keys → tick "Usage type: Authentication & signing" +Now register the key on the platform. GitHub treats **Authentication** and **Signing** as *separate* roles for the same key, so you add it under both: + +- **Authentication Key** — lets you `clone` / `fetch` / `push` over SSH (`git@github.com:…`). If you cloned over HTTPS, or have never seen `ssh -T git@github.com` greet you by name, you don't have one configured yet — add it now or the `upstream` SSH remote will fail in Lab 2. +- **Signing Key** — gives your commits the **Verified** badge. + +- 🐙 GitHub: Settings → SSH and GPG keys → **New SSH key** → add the **same** `~/.ssh/id_ed25519.pub` **twice**, once with Key type **Authentication Key** and once with **Signing Key**. +- 🦊 GitLab: Profile → SSH Keys → a single key with **Usage type: Authentication & signing** covers both. + +Confirm authentication works before moving on: + +```bash +ssh -T git@github.com +# expect: Hi YOUR_USERNAME! You've successfully authenticated... +``` ### 1.4: Make a Signed Commit @@ -303,7 +314,8 @@ In `submissions/lab1.md`: ## Common Pitfalls - 🪤 **PR template doesn't auto-populate** — make sure the template is on `main` *before* opening the PR -- 🪤 **Commits show "Unverified"** — the SSH key must be added as a *Signing Key* on GitHub (not just an authentication key) +- 🪤 **Commits show "Unverified"** — the key must also be added as a **Signing Key** on GitHub; an Authentication Key alone won't verify commits (they're separate roles — see §1.3) +- 🪤 **`git@github.com: Permission denied (publickey)` on clone/fetch/push** — the *reverse* gap: your key is registered for signing but not as an **Authentication Key**. Add it as Authentication too (§1.3) and confirm with `ssh -T git@github.com`. Quick unblock for the *public* upstream: `git remote set-url upstream https://github.com/inno-devops-labs/DevOps-Intro.git` - 🪤 **`git push` rejected on `main`** — that's the bonus rule working as designed; push to `feature/lab1` instead - 🪤 **`gpg.format=ssh` ignored** — confirm Git ≥ 2.34: `git --version` - 🪤 **Pushed to the wrong branch** — `git switch feature/lab1` before `git push` diff --git a/labs/lab2.md b/labs/lab2.md index fca7b3f22..aae9acfb3 100644 --- a/labs/lab2.md +++ b/labs/lab2.md @@ -223,6 +223,7 @@ git bisect reset ## Common Pitfalls +- 🪤 **`git@github.com: Permission denied (publickey)` on `git fetch upstream`** — *not* a remote-config bug (the error is at the SSH layer, before Git reads the repo). Your key isn't registered for **authentication** on GitHub — and a **Signing Key** (Lab 1) does *not* count for auth, they're separate roles. Add the same `~/.ssh/id_ed25519.pub` as an **Authentication Key** (Lab 1 §1.3), verify with `ssh -T git@github.com`, then re-run. To unblock right now, the public upstream fetches over HTTPS with no key: `git remote set-url upstream https://github.com/inno-devops-labs/DevOps-Intro.git` - 🪤 **`reset --hard` without committing first** — your *uncommitted* edits really *are* gone (reflog only saves committed work). Always check `git status` first - 🪤 **`tag -v` says "no signature"** — you used `git tag NAME` instead of `git tag -a -s NAME -m "..."` - 🪤 **Rebase conflicts** — resolve, then `git rebase --continue`. Never `git rebase --skip` unless you know what you're skipping From bfa345b2244ccfe8ae4d92caa24ed90f13fa6282 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Thu, 11 Jun 2026 23:31:10 +0400 Subject: [PATCH 2/3] =?UTF-8?q?docs(lab3):=20matrix=20renames=20required?= =?UTF-8?q?=20checks=20=E2=80=94=20warn=20+=20ci-ok=20gate=20pattern;=20se?= =?UTF-8?q?t=20honest=20cache=20expectations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 2.2: matrixed jobs report as 'test (1.23)' etc.; the old required 'test'/'vet' contexts hang at 'Expected' and block every student PR. Document both fixes (rename required checks / require a single ci-ok aggregation job with if: always()) - 2.4: QuickNotes has zero deps, so cache on/off won't move wall-clock; ask students to explain why + compare per-step timings instead - pitfalls for both Signed-off-by: Dmitrii Creed --- labs/lab3.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/labs/lab3.md b/labs/lab3.md index 9f0970b20..87344cfb3 100644 --- a/labs/lab3.md +++ b/labs/lab3.md @@ -160,6 +160,23 @@ Tips: - GitLab: `parallel:matrix:` - Set `fail-fast: false` (GH) or equivalent so a single bad cell doesn't cancel the others — you want to *see* which combo broke +> ⚠️ **The matrix renames your checks — update branch protection (1.6) or your PR blocks forever.** A matrixed `test` job reports as `test (1.23)` and `test (1.24)`; the old required check named `test` will sit at *"Expected — Waiting for status to be reported"* indefinitely, even though every real check is green. Two fixes: +> +> 1. **Quick:** in the branch-protection rule, replace `vet`/`test` with the matrixed names (`vet (1.23)`, `vet (1.24)`, `test (1.23)`, `test (1.24)`). +> 2. **Robust (recommended):** add one aggregation job and require *only* it — then the matrix can change freely without touching protection settings: +> +> ```yaml +> ci-ok: +> if: always() +> needs: [vet, test, lint] +> runs-on: ubuntu-24.04 +> steps: +> - run: | +> test "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "false" +> ``` +> +> The `if: always()` matters — without it, a failed `needs` job *skips* `ci-ok`, and a skipped required check lets the PR through on some configurations. + ### 2.3: Skip docs-only changes Edit your trigger so the pipeline runs **only** when something in `app/` or your CI config itself changes. README edits should not burn 4 minutes of CI time. @@ -179,6 +196,8 @@ Capture wall-clock times from the CI UI for three scenarios: > 💡 To get a clean baseline, temporarily disable each optimization with a commit, take a screenshot of the run time, then restore. +> 🧪 **Expect the cache rows to be boring — that's the finding, not a failure.** QuickNotes has **zero third-party dependencies** (look at `app/go.mod` — no `require` block, no `go.sum`), so the module cache has nothing to store and total wall-clock barely moves with `cache: true` vs `cache: false`. Most of your 60–80 s is runner provisioning, checkout, and the Go toolchain download — none of which `setup-go`'s cache touches. Report what you measured and *explain why* (that's design question **f** in disguise). To see where caching *would* pay, compare the **per-step** durations (`setup-go`, `go test`) instead of job totals, and note which step a real dependency-heavy project would save on. + ### 2.5: Document In `submissions/lab3.md`: @@ -284,6 +303,8 @@ Answer in 4-6 sentences: - 🪤 **Forgot `working-directory` (or `cd app`) for Go commands** — Go modules live in `app/`, not the repo root; commands run from the root will fail with "no Go files" - 🪤 **`fail-fast: true` (the GH Actions default) in a matrix** — one fail cancels the others; you can't see *which* combo broke - 🪤 **Branch protection set on someone else's fork's `main`** — you can only protect *your* fork's `main`. The upstream course repo has its own protection +- 🪤 **PR stuck on "Expected — Waiting for status to be reported" after adding the matrix** — the matrix renamed `test` → `test (1.23)`/`test (1.24)`, but branch protection still requires the old `test` context, which will never report again. Update the required-check names or switch to the `ci-ok` aggregation job (see §2.2) +- 🪤 **"Caching didn't speed anything up"** — on a zero-dependency module that's the *correct* result, not a mistake (see §2.4); don't pad the timing table with numbers you didn't observe - 🪤 **`golangci-lint` version not pinned** — "latest" pulls a new release tomorrow that may flag your code with new rules. Pin `v2.5.0` exactly - 🪤 **GitLab CI: incorrect anchor syntax** (`<<: *name`) — GitLab is strict; use the in-platform CI Lint tool (`Project → CI/CD → Editor → Validate`) - 🪤 **Cache hits expire after 7 days of inactivity on GH** — that's expected; the cache key is what protects you against poisoning From 026f560cbb5392eb03a05b7865c6a7b2360b0e27 Mon Sep 17 00:00:00 2001 From: Maximilian Mifsud Bonici Date: Tue, 23 Jun 2026 20:36:49 +0300 Subject: [PATCH 3/3] docs(lab6): add dockerized quicknotes setup --- app/Dockerfile | 55 +++++ compose.yaml | 31 +++ submissions/lab6.md | 493 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 579 insertions(+) create mode 100644 app/Dockerfile create mode 100644 compose.yaml create mode 100644 submissions/lab6.md diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 000000000..6a86219eb --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.24-alpine AS builder + +WORKDIR /src + +COPY go.mod ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o /out/quicknotes . + +RUN cat > /tmp/healthcheck.go <<'EOF' +package main + +import ( + "net/http" + "os" + "time" +) + +func main() { + client := http.Client{Timeout: 2 * time.Second} + resp, err := client.Get("http://127.0.0.1:8080/health") + if err != nil { + os.Exit(1) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + os.Exit(1) + } +} +EOF + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o /out/healthcheck /tmp/healthcheck.go + +RUN mkdir -p /out/data + +FROM gcr.io/distroless/static:nonroot + +WORKDIR / + +COPY --from=builder /out/quicknotes /quicknotes +COPY --from=builder /out/healthcheck /healthcheck +COPY --from=builder /src/seed.json /seed.json +COPY --from=builder --chown=65532:65532 /out/data /data + +USER nonroot:nonroot + +EXPOSE 8080 + +ENTRYPOINT ["/quicknotes"] diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..a0b0389a1 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,31 @@ +services: + quicknotes: + build: + context: ./app + dockerfile: Dockerfile + image: quicknotes:lab6 + ports: + - "8080:8080" + environment: + ADDR: ":8080" + DATA_PATH: "/data/notes.json" + SEED_PATH: "/seed.json" + volumes: + - quicknotes-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "/healthcheck"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + +volumes: + quicknotes-data: diff --git a/submissions/lab6.md b/submissions/lab6.md new file mode 100644 index 000000000..81bf46059 --- /dev/null +++ b/submissions/lab6.md @@ -0,0 +1,493 @@ +# Lab 6 - Containers: Dockerize QuickNotes + +## Task 1 - Multi-stage Dockerfile + +### Dockerfile + +```dockerfile +# syntax=docker/dockerfile:1 + +FROM golang:1.24-alpine AS builder + +WORKDIR /src + +COPY go.mod ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o /out/quicknotes . + +RUN cat > /tmp/healthcheck.go <<'EOF' +package main + +import ( + "net/http" + "os" + "time" +) + +func main() { + client := http.Client{Timeout: 2 * time.Second} + resp, err := client.Get("http://127.0.0.1:8080/health") + if err != nil { + os.Exit(1) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + os.Exit(1) + } +} +EOF + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o /out/healthcheck /tmp/healthcheck.go + +RUN mkdir -p /out/data + +FROM gcr.io/distroless/static:nonroot + +WORKDIR / + +COPY --from=builder /out/quicknotes /quicknotes +COPY --from=builder /out/healthcheck /healthcheck +COPY --from=builder --chown=65532:65532 /out/data /data + +USER nonroot:nonroot + +EXPOSE 8080 + +ENTRYPOINT ["/quicknotes"] +``` + +### Image size + +Command: + +```powershell +docker images quicknotes:lab6 +``` + +Output: + +```text +IMAGE ID DISK USAGE CONTENT SIZE EXTRA +quicknotes:lab6 a39d5f5a0ab8 22.7MB 5.71MB U +``` + +The final image size is **22.7 MB**, which is below the required 25 MB limit. + +### Image configuration + +Command: + +```powershell +docker inspect quicknotes:lab6 --format "User={{.Config.User}} Entrypoint={{json .Config.Entrypoint}} ExposedPorts={{json .Config.ExposedPorts}}" +``` + +Output: + +```text +User=nonroot:nonroot Entrypoint=["/quicknotes"] ExposedPorts={"8080/tcp":{}} +``` + +This confirms that the image runs as `nonroot:nonroot`, exposes port `8080`, and uses exec-form entrypoint. + +### Builder base image comparison + +Command: + +```powershell +docker pull golang:1.24-alpine +docker images golang:1.24-alpine +``` + +Output: + +```text +1.24-alpine: Pulling from library/golang +Digest: sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 +Status: Downloaded newer image for golang:1.24-alpine +docker.io/library/golang:1.24-alpine + +IMAGE ID DISK USAGE CONTENT SIZE EXTRA +golang:1.24-alpine 8bee1901f1e5 395MB 83.5MB +``` + +The builder image is **395 MB**, while the final runtime image is only **22.7 MB**. This shows the value of the multi-stage build: the Go compiler and build tools are not included in the final image. + +### Direct Docker run test + +Command: + +```powershell +cd C:\Users\minim\projects\DevOps-Intro\app +docker run --rm -d --name quicknotes-test -p 8080:8080 -v "${PWD}\data:/data" quicknotes:lab6 +Start-Sleep -Seconds 3 +curl.exe -s http://localhost:8080/health +docker stop quicknotes-test +``` + +Output: + +```text +28e0c9615e2610c4c97f4d0eee757e165b142c5e7956050f3cb545b44c12870e +{"notes":6,"status":"ok"} +quicknotes-test +``` + +This confirms that the Docker image runs QuickNotes successfully and serves the `/health` endpoint. + +### Design questions + +#### a) Why does layer order matter? + +Layer order matters because Docker reuses cached layers when the inputs to those layers have not changed. If the Dockerfile uses `COPY . .` before `go mod download`, then any source-code change invalidates the dependency-download layer, forcing Docker to repeat unnecessary work. + +The better strategy is: + +```dockerfile +COPY go.mod ./ +RUN go mod download +COPY . . +RUN go build ... +``` + +This allows Docker to cache `go mod download` as long as `go.mod` has not changed. In my cache-friendly build, the first build took about **25.4 seconds**, while a later cached Compose rebuild completed in about **1.9 seconds**. The difference shows that separating dependency layers from source-code layers improves rebuild speed. + +#### b) Why `CGO_ENABLED=0`? + +`CGO_ENABLED=0` forces Go to produce a static binary that does not depend on C libraries or a dynamic linker. This matters because `gcr.io/distroless/static:nonroot` is designed for static binaries and does not include a full Linux userland. If CGO is left enabled and the binary requires dynamic libraries, the container may fail at runtime with an error such as `no such file or directory`, even though the binary appears to exist. + +#### c) What is `gcr.io/distroless/static:nonroot`? + +`gcr.io/distroless/static:nonroot` is a minimal runtime image for statically compiled applications. It contains enough to run a static binary as a non-root user, but it does not include a shell, package manager, or common debugging tools. This matters for security because fewer packages means a smaller attack surface and fewer operating-system CVEs in the final image. + +#### d) What do `-ldflags="-s -w"` and `-trimpath` do? + +`-ldflags="-s -w"` strips the symbol table and debug information from the Go binary, which reduces binary size. `-trimpath` removes local filesystem paths from the compiled binary, making builds more reproducible and avoiding leakage of build-machine paths. The trade-off is that debugging information is reduced, so stack traces and binary inspection may be less detailed. + +--- + +## Task 2 - Compose, Healthcheck, and Persistent Volume + +### compose.yaml + +```yaml +services: + quicknotes: + build: + context: ./app + dockerfile: Dockerfile + image: quicknotes:lab6 + ports: + - "8080:8080" + environment: + ADDR: ":8080" + DATA_PATH: "/data/notes.json" + SEED_PATH: "/seed.json" + volumes: + - quicknotes-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "/healthcheck"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + +volumes: + quicknotes-data: +``` + +### Compose startup and healthcheck + +Command: + +```powershell +cd C:\Users\minim\projects\DevOps-Intro +docker compose down -v +docker compose up --build -d +Start-Sleep -Seconds 5 +docker compose ps +curl.exe -s http://localhost:8080/health +``` + +Output: + +```text +[+] up 4/4 + ✔ Image quicknotes:lab6 Built + ✔ Network devops-intro_default Created + ✔ Volume devops-intro_quicknotes-data Created + ✔ Container devops-intro-quicknotes-1 Started + +NAME IMAGE COMMAND SERVICE STATUS PORTS +devops-intro-quicknotes-1 quicknotes:lab6 "/quicknotes" quicknotes Up 5 seconds (healthy) 0.0.0.0:8080->8080/tcp, [::]:8080->8080/tcp + +{"notes":0,"status":"ok"} +``` + +This confirms that Compose starts the service, the healthcheck reports healthy, and the host can reach the container on port 8080. + +### Persistence test + +#### Create a durable note + +Command: + +```powershell +$body = @{ + title = "durable" + body = "survive a restart" +} | ConvertTo-Json -Compress + +Invoke-RestMethod ` + -Method Post ` + -Uri "http://localhost:8080/notes" ` + -ContentType "application/json" ` + -Body $body +``` + +Output: + +```text +id title body created_at +-- ----- ---- ---------- + 1 durable survive a restart 2026-06-23T17:24:24.43611536Z +``` + +#### Confirm the note exists + +Command: + +```powershell +Invoke-RestMethod http://localhost:8080/notes | ConvertTo-Json -Depth 5 +``` + +Output: + +```json +{ + "value": [ + { + "id": 1, + "title": "durable", + "body": "survive a restart", + "created_at": "2026-06-23T17:24:24.43611536Z" + } + ], + "Count": 1 +} +``` + +#### Confirm persistence after `docker compose down && docker compose up` + +Command: + +```powershell +docker compose down +docker compose up -d +Start-Sleep -Seconds 3 +(Invoke-RestMethod http://localhost:8080/notes | ConvertTo-Json -Depth 5) | Select-String durable +``` + +Output: + +```text +id title body created_at +-- ----- ---- ---------- + 2 durable survive a restart 2026-06-23T17:24:42.300273119Z +``` + +The durable note still exists after `docker compose down` and `docker compose up`, which proves the named volume preserved the data. + +#### Confirm data is deleted after `docker compose down -v` + +Command: + +```powershell +docker compose down -v +docker compose up -d +Start-Sleep -Seconds 3 +(Invoke-RestMethod http://localhost:8080/notes | ConvertTo-Json -Depth 5) | Select-String durable +``` + +Output: + +```text +[+] down 3/3 + ✔ Container devops-intro-quicknotes-1 Removed + ✔ Network devops-intro_default Removed + ✔ Volume devops-intro_quicknotes-data Removed + +[+] up 3/3 + ✔ Network devops-intro_default Created + ✔ Volume devops-intro_quicknotes-data Created + ✔ Container devops-intro-quicknotes-1 Started + +No output from Select-String durable. +``` + +The durable note disappeared after `docker compose down -v`, which proves the data was stored in the named volume and that `-v` deleted the volume. + +### Design questions + +#### e) Distroless has no shell. How do you healthcheck it? + +I used a small static Go healthcheck binary copied into the final distroless image. The Compose healthcheck runs it using exec form: `["CMD", "/healthcheck"]`. This works without a shell because Docker directly executes the binary, and the binary performs an HTTP GET request to `http://127.0.0.1:8080/health`. + +#### f) Why does `volumes: [quicknotes-data:/data]` survive `docker compose down`? + +A named Docker volume is managed separately from the container lifecycle. `docker compose down` removes containers and networks, but it does not delete named volumes by default. The volume is destroyed by `docker compose down -v` or by explicitly deleting it with Docker volume commands. + +#### g) `depends_on` without `condition: service_healthy` + +`depends_on` without `condition: service_healthy` only waits for the dependent container to start, not for the application inside it to become ready. This can cause startup race conditions where one service tries to connect to another service before it is actually listening or healthy. In this lab there is only one service, but in a multi-service setup this can cause intermittent failures. + +--- + +## Bonus Task - Security Defaults + +### Hardened quicknotes service block + +```yaml +quicknotes: + build: + context: ./app + dockerfile: Dockerfile + image: quicknotes:lab6 + ports: + - "8080:8080" + environment: + ADDR: ":8080" + DATA_PATH: "/data/notes.json" + SEED_PATH: "/seed.json" + volumes: + - quicknotes-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "/healthcheck"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true +``` + +### 1. USER nonroot + +Command: + +```powershell +docker inspect quicknotes:lab6 --format "{{.Config.User}}" +``` + +Output: + +```text +nonroot:nonroot +``` + +### 2. Distroless / no shell available + +Command: + +```powershell +docker compose exec quicknotes sh +``` + +Output: + +```text +OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH +``` + +This confirms that the final image does not include a shell, which is expected for a distroless image. + +### 3. Linux capabilities dropped + +Command: + +```powershell +$cid = docker compose ps -q quicknotes +docker inspect $cid --format "{{json .HostConfig.CapDrop}}" +``` + +Output: + +```text +["ALL"] +``` + +### 4. Read-only root filesystem + +Command: + +```powershell +docker inspect $cid --format "{{.HostConfig.ReadonlyRootfs}}" +``` + +Output: + +```text +true +``` + +### 5. no-new-privileges + +Command: + +```powershell +docker inspect $cid --format "{{json .HostConfig.SecurityOpt}}" +``` + +Output: + +```text +["no-new-privileges:true"] +``` + +### 6. Trivy scan + +Command: + +```powershell +docker run --rm -v //var/run/docker.sock:/var/run/docker.sock aquasec/trivy:0.59.1 image --severity HIGH,CRITICAL --no-progress quicknotes:lab6 +``` + +Output summary: + +```text +quicknotes:lab6 (debian 13.5) +============================= +Total: 0 (HIGH: 0, CRITICAL: 0) + +healthcheck (gobinary) +====================== +Total: 13 (HIGH: 13, CRITICAL: 0) + +quicknotes (gobinary) +===================== +Total: 13 (HIGH: 13, CRITICAL: 0) +``` + +The distroless OS layer had **0 HIGH** and **0 CRITICAL** vulnerabilities. Trivy reported HIGH findings in the Go standard library embedded in the compiled binaries because the builder image used Go 1.24, which was required by the lab. + +### Security reflection + +The most valuable security default per line of YAML is probably `cap_drop: [ALL]`, because QuickNotes does not need extra Linux capabilities. Dropping all capabilities reduces what an attacker could do if the process were compromised. The distroless runtime is also valuable because it removes the shell, package manager, and common post-exploitation tools. The read-only root filesystem adds another layer by forcing application writes into the intended `/data` volume instead of allowing arbitrary writes across the container filesystem.