127 lines
6.3 KiB
Markdown
127 lines
6.3 KiB
Markdown
# 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.
|