Add new files
This commit is contained in:
parent
08bb236fa1
commit
2557728485
126
README.md
Normal file
126
README.md
Normal 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
321
infrastructure-summary.html
Normal 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.0–v3.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>
|
||||
Loading…
Reference in New Issue
Block a user