Go to file
2026-09-04 11:49:24 +02:00
api FIrst commit of the files 2026-09-04 11:13:58 +02:00
website FIrst commit of the files 2026-09-04 11:13:58 +02:00
docker-compose.yml FIrst commit of the files 2026-09-04 11:13:58 +02:00
infrastructure-summary.html Add new files 2026-09-04 11:49:24 +02:00
package.json FIrst commit of the files 2026-09-04 11:13:58 +02:00
README.md Add new files 2026-09-04 11:49:24 +02:00

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-loopedread_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.