| api | ||
| website | ||
| docker-compose.yml | ||
| infrastructure-summary.html | ||
| package.json | ||
| README.md | ||
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.apiis also attached here so it can reachpostgres:5432once 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.sockdirectly — 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
- Copy
.env.exampleto.envand fill in real values. Never commit.env. - Point DNS
Arecords forbabypistachio.com,www.babypistachio.com,ci.babypistachio.com,git.babypistachio.com,api.babypistachio.comat the VPS's IP before starting. docker compose up -d --build- 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:
-
npm cifailed in the API build — nopackage-lock.jsonexisted. Fixed by generating one (npm install --package-lock-only) and committing it alongsidepackage.json.npm auditalso caught a moderateqs/body-parser vulnerability, fixed vianpm audit fix(now on express 4.22.2). -
socket-proxy crash-looped —
read_only: trueon that container blocked it from writing its HAProxy config at startup. Removedread_onlyfor 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). -
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_VERSIONenv var did nothing -- Traefik's Docker client doesn't read it.) -
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 /dataAlso corrected the
dockergroup GID ingroup_addto match this VPS's actual GID (988, found viagetent group docker) -- needed for Jenkins to use the mounted Docker socket for CI builds. -
api.babypistachio.comreturned Gateway Timeout -- theapiservice sits on two networks (proxyandinternal), and Traefik can't infer which one to route through when a container has more than one. Fixed with thetraefik.docker.network=proxylabel. 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>_proxyname, 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-headersmiddleware. apihas its own Dockerfile with dependencies baked in and locked viapackage-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.