Add new files

This commit is contained in:
root 2026-09-04 11:49:24 +02:00
parent 08bb236fa1
commit 2557728485
2 changed files with 447 additions and 0 deletions

126
README.md Normal file
View File

@ -0,0 +1,126 @@
# babypistachio.com — backend test environment
## What this is
A VPS set up as a backend test environment for mobile app development,
with a reverse proxy fronting everything, HTTPS via Let's Encrypt, and
isolated networking so the database is never internet-reachable.
## Services
| Service | Image | Reachable at | Purpose |
|-----------|---------------------------------|----------------------------|---------|
| traefik | traefik:v3.6 | ports 80/443 (public) | Reverse proxy, TLS termination, auto HTTPS via Let's Encrypt, routes all subdomains |
| socket-proxy | tecnativa/docker-socket-proxy:0.3 | internal only | Lets Traefik discover containers without mounting the raw Docker socket |
| apache | httpd:2.4 | babypistachio.com, www.babypistachio.com | Static website |
| gitea | gitea/gitea:1.22 | git.babypistachio.com | Git repository hosting |
| jenkins | jenkins/jenkins:lts-jdk21 | ci.babypistachio.com | CI/CD |
| api | built from ./api (node:22-alpine) | api.babypistachio.com/health | Placeholder API confirming the environment works end-to-end |
| postgres | postgres:16 | internal only, no public route | Database for the future app (not yet wired into any service) |
## Network layout
- **proxy** — internet-facing. Traefik + apache/gitea/jenkins/api sit here.
- **internal** (`internal: true`) — no internet access in or out. Postgres
lives here, with no published ports. `api` is also attached here so it
can reach `postgres:5432` once the real app is deployed, without ever
exposing Postgres itself externally.
- **socket-proxy** — isolated network between Traefik and `docker-socket-proxy`,
so Traefik never touches `/var/run/docker.sock` directly — it only gets
read-only container-listing access.
All three networks are given fixed names (`proxy`, `internal`, `socket-proxy`)
in the compose file rather than left to Compose's auto-prefixing, so labels
like `traefik.docker.network=proxy` keep working regardless of what the
project folder is called.
## Setup
1. Copy `.env.example` to `.env` and fill in real values. Never commit `.env`.
2. Point DNS `A` records for `babypistachio.com`, `www.babypistachio.com`,
`ci.babypistachio.com`, `git.babypistachio.com`, `api.babypistachio.com`
at the VPS's IP before starting.
3. `docker compose up -d --build`
4. One-time Jenkins volume fix (see Troubleshooting notes below) — only
needed on first-ever startup.
## Troubleshooting notes (from getting this running)
These were the real issues hit during setup, kept here so future changes
don't reintroduce them:
1. **`npm ci` failed in the API build** — no `package-lock.json` existed.
Fixed by generating one (`npm install --package-lock-only`) and
committing it alongside `package.json`. `npm audit` also caught a
moderate `qs`/body-parser vulnerability, fixed via `npm audit fix`
(now on express 4.22.2).
2. **socket-proxy crash-looped**`read_only: true` on that container
blocked it from writing its HAProxy config at startup. Removed
`read_only` for this one low-risk, internal-only container rather
than fight it with tmpfs mounts (a tmpfs over the config directory
also wiped out a template file baked into the image, causing a
second failure).
3. **Traefik couldn't talk to Docker: "client version 1.24 is too old"**
— a known bug in Traefik v3.0-v3.5, which hardcode API version 1.24
regardless of the actual Docker Engine version. Fixed by upgrading
the Traefik image to v3.6, which negotiates the API version properly.
(An earlier attempt to fix this via a `DOCKER_API_VERSION` env var
did nothing -- Traefik's Docker client doesn't read it.)
4. **Jenkins crash-looped: "Permission denied" writing to `/var/jenkins_home`**
-- the official Jenkins image runs as UID 1000, but ships no seed files
for Docker to copy into a fresh named volume, so the volume was created
owned by root. Fixed once, permanently, with:
```
docker run --rm -v docker_jenkins_home:/data busybox chown -R 1000:1000 /data
```
Also corrected the `docker` group GID in `group_add` to match this
VPS's actual GID (988, found via `getent group docker`) -- needed for
Jenkins to use the mounted Docker socket for CI builds.
5. **`api.babypistachio.com` returned Gateway Timeout** -- the `api`
service sits on two networks (`proxy` and `internal`), and Traefik
can't infer which one to route through when a container has more
than one. Fixed with the `traefik.docker.network=proxy` label. This
is also why network names are pinned explicitly in this file (see
Network layout above) -- without that, the label would need to match
Compose's auto-generated `<project>_proxy` name, which changes if
the folder is renamed.
## Known trade-off: Jenkins and the Docker socket
Jenkins mounts `/var/run/docker.sock` directly (read-write) because CI/CD
needs to build and run Docker images -- more Docker API access than a
restricted socket-proxy can safely grant without mostly defeating the
purpose. In practice, **anyone who compromises Jenkins has root-equivalent
control of the host.**
Options if you want to close this gap later:
- Run builds in isolated Docker-in-Docker (`docker:dind`) agents instead
of mounting the host socket
- Use Jenkins agents on separate VMs
- Rootless Docker
Acceptable trade-off for a test environment; don't put production secrets
on this host as-is.
## Other security decisions
- No hardcoded secrets -- everything sensitive comes from `.env`.
- Traefik dashboard (`api.dashboard=true`) is enabled but has no router
exposing it externally -- not reachable from the internet as configured.
- Security headers (HSTS, X-Frame-Options, nosniff, XSS filter) applied
to every public router via the `sec-headers` middleware.
- `api` has its own Dockerfile with dependencies baked in and locked via
`package-lock.json` -- no reinstalling on every restart.
- VPS-level firewall (ufw) should still only allow 22/80/443 in -- don't
rely on Docker network isolation alone.
## Next stage
Postgres isn't wired into any service yet. When the real app is deployed,
`api` (or whatever replaces it) can reach it at `postgres:5432` over the
`internal` network -- no code changes needed to the network setup, just
add the connection logic.

321
infrastructure-summary.html Normal file
View File

@ -0,0 +1,321 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>babypistachio.com — Infrastructure Summary</title>
<style>
:root {
--paper: #f7f5f0;
--ink: #1c1b19;
--ink-soft: #55524a;
--rule: #d8d3c6;
--accent: #3a5a52;
--accent-soft: #e4ebe8;
--mono-bg: #eeeae0;
--tag-internal: #6b5a3e;
--tag-public: #3a5a52;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
line-height: 1.6;
}
.wrap {
max-width: 760px;
margin: 0 auto;
padding: 4rem 1.5rem 6rem;
}
header.doc-head {
border-bottom: 1px solid var(--rule);
padding-bottom: 1.75rem;
margin-bottom: 2.5rem;
}
.doc-kicker {
font-family: "SF Mono", "Menlo", monospace;
font-size: 0.78rem;
color: var(--ink-soft);
letter-spacing: 0.02em;
}
h1 {
font-size: 2.1rem;
font-weight: 600;
margin: 0.4rem 0 0.6rem;
letter-spacing: -0.01em;
}
.doc-sub {
color: var(--ink-soft);
font-size: 1.02rem;
max-width: 58ch;
}
h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 3rem 0 1rem;
padding-top: 0.25rem;
}
h2 .num {
font-family: "SF Mono", "Menlo", monospace;
color: var(--accent);
font-size: 0.95rem;
margin-right: 0.6rem;
}
p { margin: 0.9rem 0; }
code {
font-family: "SF Mono", "Menlo", monospace;
background: var(--mono-bg);
padding: 0.12em 0.4em;
border-radius: 3px;
font-size: 0.88em;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0 0.5rem;
font-size: 0.95rem;
}
th {
text-align: left;
font-family: "SF Mono", "Menlo", monospace;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-soft);
font-weight: 500;
padding: 0 0.9rem 0.6rem 0;
border-bottom: 1px solid var(--rule);
}
td {
padding: 0.75rem 0.9rem 0.75rem 0;
border-bottom: 1px solid var(--rule);
vertical-align: top;
}
td.svc code {
font-weight: 600;
}
tr:last-child td { border-bottom: none; }
.tag {
display: inline-block;
font-family: "SF Mono", "Menlo", monospace;
font-size: 0.7rem;
padding: 0.15em 0.55em;
border-radius: 3px;
letter-spacing: 0.02em;
}
.tag.public { background: var(--accent-soft); color: var(--tag-public); }
.tag.internal { background: #f1ebe0; color: var(--tag-internal); }
.net-list {
list-style: none;
padding: 0;
margin: 1rem 0;
}
.net-list li {
padding: 0.9rem 0;
border-bottom: 1px solid var(--rule);
}
.net-list li:last-child { border-bottom: none; }
.net-list .net-name {
font-family: "SF Mono", "Menlo", monospace;
font-weight: 600;
color: var(--accent);
}
.net-list p { margin: 0.3rem 0 0; color: var(--ink-soft); font-size: 0.95rem; }
.callout {
border-left: 3px solid var(--accent);
background: var(--accent-soft);
padding: 1rem 1.25rem;
margin: 1.5rem 0;
border-radius: 0 4px 4px 0;
}
.callout p { margin: 0; font-size: 0.96rem; }
.callout .callout-label {
font-family: "SF Mono", "Menlo", monospace;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--accent);
display: block;
margin-bottom: 0.4rem;
}
.risk {
border-left: 3px solid var(--tag-internal);
background: #f1ebe0;
padding: 1rem 1.25rem;
margin: 1.5rem 0;
border-radius: 0 4px 4px 0;
}
.risk p { margin: 0; font-size: 0.96rem; }
.risk .risk-label {
font-family: "SF Mono", "Menlo", monospace;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--tag-internal);
display: block;
margin-bottom: 0.4rem;
}
.filelist {
list-style: none;
padding: 0;
margin: 1rem 0;
font-family: "SF Mono", "Menlo", monospace;
font-size: 0.9rem;
}
.filelist li {
padding: 0.4rem 0;
color: var(--ink-soft);
}
.filelist li strong {
color: var(--ink);
font-weight: 600;
}
.filelist .indent { padding-left: 1.4rem; }
footer {
margin-top: 3.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--rule);
font-size: 0.85rem;
color: var(--ink-soft);
}
</style>
</head>
<body>
<div class="wrap">
<header class="doc-head">
<div class="doc-kicker">infrastructure summary — stage 1</div>
<h1>babypistachio.com</h1>
<p class="doc-sub">Backend mobile-development test environment on a single VPS, fronted by a reverse proxy with automatic HTTPS.</p>
</header>
<section>
<h2><span class="num">01</span>Services</h2>
<table>
<thead>
<tr><th>Service</th><th>Image</th><th>Reachable at</th></tr>
</thead>
<tbody>
<tr>
<td class="svc"><code>traefik</code></td>
<td>traefik:v3.6</td>
<td><span class="tag public">public</span> ports 80 / 443 — reverse proxy, TLS termination, routes every subdomain</td>
</tr>
<tr>
<td class="svc"><code>socket-proxy</code></td>
<td>tecnativa/docker-socket-proxy:0.3</td>
<td><span class="tag internal">internal</span> lets Traefik discover containers without touching the raw Docker socket</td>
</tr>
<tr>
<td class="svc"><code>apache</code></td>
<td>httpd:2.4</td>
<td><span class="tag public">public</span> babypistachio.com — static site</td>
</tr>
<tr>
<td class="svc"><code>gitea</code></td>
<td>gitea/gitea:1.22</td>
<td><span class="tag public">public</span> git.babypistachio.com — repository hosting</td>
</tr>
<tr>
<td class="svc"><code>jenkins</code></td>
<td>jenkins/jenkins:lts-jdk21</td>
<td><span class="tag public">public</span> ci.babypistachio.com — CI/CD</td>
</tr>
<tr>
<td class="svc"><code>api</code></td>
<td>built from ./api (node:22-alpine)</td>
<td><span class="tag public">public</span> api.babypistachio.com/health — placeholder service confirming the environment works end to end</td>
</tr>
<tr>
<td class="svc"><code>postgres</code></td>
<td>postgres:16</td>
<td><span class="tag internal">internal</span> no public route, not yet wired into any service — ready for the real app</td>
</tr>
</tbody>
</table>
</section>
<section>
<h2><span class="num">02</span>Network layout</h2>
<ul class="net-list">
<li>
<span class="net-name">proxy</span> — internet-facing
<p>Traefik, apache, gitea, jenkins, and api sit here.</p>
</li>
<li>
<span class="net-name">internal</span> — no internet access, in or out
<p>Postgres lives here with no published ports. api is also attached so it can reach postgres:5432 once the real app is deployed, without ever exposing the database externally.</p>
</li>
<li>
<span class="net-name">socket-proxy</span> — isolated
<p>Connects Traefik to docker-socket-proxy only, so Traefik never mounts the host's Docker socket directly.</p>
</li>
</ul>
</section>
<section>
<h2><span class="num">03</span>What got fixed along the way</h2>
<p>Five real issues surfaced while bringing this up, each documented in the repo's README so the fix doesn't get silently undone by a future change:</p>
<ul>
<li>Missing <code>package-lock.json</code> broke the API's Docker build (<code>npm ci</code> requires one); also caught and fixed a moderate <code>qs</code> vulnerability via <code>npm audit fix</code>.</li>
<li><code>socket-proxy</code> crash-looped under <code>read_only: true</code>, which blocked it from writing its own config at startup.</li>
<li>Traefik couldn't talk to Docker at all — a known bug in Traefik v3.0v3.5 that hardcodes an old API version. Fixed by upgrading to v3.6.</li>
<li>Jenkins crash-looped on a permission error — the named volume was created root-owned since the image ships no seed files. Fixed with a one-time <code>chown</code>.</li>
<li><code>api.babypistachio.com</code> returned Gateway Timeout because it sits on two Docker networks and Traefik couldn't infer which one to route through — fixed with an explicit <code>traefik.docker.network</code> label.</li>
</ul>
</section>
<section>
<h2><span class="num">04</span>Files</h2>
<ul class="filelist">
<li><strong>docker-compose.yml</strong> — full stack definition</li>
<li><strong>README.md</strong> — setup steps, network layout, troubleshooting log</li>
<li><strong>.env.example</strong> — template for domain, ACME email, Postgres credentials</li>
<li><strong>api/</strong></li>
<li class="indent">Dockerfile, package.json, package-lock.json, server.js</li>
<li><strong>website/</strong></li>
<li class="indent">index.html</li>
</ul>
</section>
<div class="callout">
<span class="callout-label">Security decisions worth knowing</span>
<p>No hardcoded secrets — everything sensitive comes from <code>.env</code>. Traefik's dashboard is enabled but has no external router, so it isn't reachable from the internet. Security headers (HSTS, X-Frame-Options, nosniff, XSS filter) apply to every public router. The VPS firewall should still restrict inbound traffic to 22/80/443 — Docker network isolation isn't a substitute for that.</p>
</div>
<div class="risk">
<span class="risk-label">Open risk — not yet resolved</span>
<p>Jenkins mounts the host Docker socket directly to support CI/CD builds. Anyone who compromises Jenkins has root-equivalent control of the host. Reasonable trade-off for a test environment, but not something to carry into production as-is — see the README for mitigation options (Docker-in-Docker agents, separate build VMs, rootless Docker).</p>
</div>
<footer>
Stage 1 — infrastructure — complete. Postgres is provisioned but not yet connected to any service; that's the starting point for the next stage.
</footer>
</div>
</body>
</html>