diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..8424e20 --- /dev/null +++ b/server/README.md @@ -0,0 +1,53 @@ +# server/ — the payloads + +Everything under this directory is **data**. These files are copied to the hosts +verbatim: no templating, no variable substitution by Ansible, no generation. + +``` +server/// a stack that runs on exactly one host +server/shared// a payload used by more than one host or instance +server/shared/components/ single files that belong to another stack's directory +``` + +The directory names under `server/` must match the inventory host names, because +the role resolves a payload as `server/{{ inventory_hostname }}/{{ stack_name }}` +unless a playbook overrides `stack_src`. + +`server/` is excluded from `ansible-lint` and `yamllint`. A compose file is not +Ansible content, and most of these come from upstream projects that format them +their own way — linting them produces noise and pressure to reformat files you +want to be able to diff against upstream. + +## The examples + +All six are fictional. `example.com` is reserved by RFC 2606 and can never +resolve to a real service. Replace them with your own; the framework around them +is the reusable part. + +| Payload | Used by | Notes | +| --- | --- | --- | +| `edge/reverse-proxy/` | `reverse-proxy.yml` | nginx-proxy + acme-companion. Owns `proxy-net`. Its `proxy-data/` subdirectories are bind mounts holding plain config files. | +| `app/static-site/` | `static-site.yml` | One container, one network, no state. The simplest thing that works. | +| `app/webapp/` | `webapp.yml`, `webapp-staging.yml` | Postgres plus an app built from `app/Dockerfile`. A second compose file holds maintenance-only services. Every credential comes from `.env`. | +| `shared/metrics/` | `metrics.yml` | Prometheus and Grafana, identical on both hosts, so it lives here rather than being duplicated per host. | +| `shared/components/banner.html` | `banner.yml` | A single file that belongs to the reverse proxy's directory on every host. | + +## Conventions worth copying + +**Take secrets from `.env`, and require them.** Every credential in these +payloads is written as `${VAR:?message}`. Compose then refuses to start and names +the missing variable, instead of expanding it to an empty string and starting the +service with no password. Ship a `.env.example` listing the names with no values; +the role never syncs `.env` itself. + +**Pin image tags.** `nginx:1.27-alpine`, not `nginx:latest`. A `latest` tag means +a plain deploy will not pick up a new build (Compose keeps the image it has) and +an update run picks up whatever was published this morning. Both are surprises. + +**Declare shared networks external.** A network two stacks need is created by the +playbook through `stack_networks`, not by whichever compose file happened to run +first. That is what lets every playbook stand on its own. + +**Do not put host paths in the compose file.** Use `${PWD}` if a sibling +container needs one — the role exports it as the stack's directory on the host, +which is the only value that is correct from inside a deploy. diff --git a/server/app/static-site/docker-compose.yml b/server/app/static-site/docker-compose.yml new file mode 100644 index 0000000..de3d253 --- /dev/null +++ b/server/app/static-site/docker-compose.yml @@ -0,0 +1,23 @@ +# The smallest stack there is: one container, one network, no state. +# +# Deployed by ansible/playbooks/static-site.yml, which is four lines of variables +# and the stack this repository points you at when you add your first service. +services: + static-site: + container_name: static-site + image: nginx:1.27-alpine + restart: unless-stopped + networks: + - proxy-net + environment: + # Read by the reverse proxy's docker-gen companion, which writes the vhost + # and requests the certificate. Nothing here talks to the proxy directly. + VIRTUAL_HOST: www.example.com + VIRTUAL_PORT: "80" + LETSENCRYPT_HOST: www.example.com + volumes: + - ./html:/usr/share/nginx/html:ro + +networks: + proxy-net: + external: true diff --git a/server/app/static-site/html/index.html b/server/app/static-site/html/index.html new file mode 100644 index 0000000..11091ee --- /dev/null +++ b/server/app/static-site/html/index.html @@ -0,0 +1,9 @@ + + +static-site +

static-site

+

+ Served from server/app/static-site/html/ in the configuration + repository, synced to /srv/stacks/static-site/html on the + app host. +

diff --git a/server/app/webapp/.env.example b/server/app/webapp/.env.example new file mode 100644 index 0000000..d692182 --- /dev/null +++ b/server/app/webapp/.env.example @@ -0,0 +1,11 @@ +# Copy to .env ON THE HOST, not here. The compose_stack role excludes .env from +# every sync (stack_exclude), so a .env committed to this repository would be +# ignored, and a host's .env is never overwritten by a deploy. +# +# ssh deploy@app.example.com +# cd /srv/stacks/webapp && cp .env.example .env && $EDITOR .env +# +# webapp.yml refuses to deploy if .env is absent on the host, because every +# variable below is required with no default. +POSTGRES_PASSWORD= +SECRET_KEY= diff --git a/server/app/webapp/app/Dockerfile b/server/app/webapp/app/Dockerfile new file mode 100644 index 0000000..ccd29e3 --- /dev/null +++ b/server/app/webapp/app/Dockerfile @@ -0,0 +1,11 @@ +# Stands in for a real application image. The point of building from the payload +# rather than pulling is that stack_build then controls when a rebuild happens — +# see "Deploy versus update" in the README. +FROM python:3.12-slim + +WORKDIR /app +COPY serve.py migrate.sh ./ +RUN chmod +x migrate.sh + +EXPOSE 8080 +CMD ["python", "serve.py"] diff --git a/server/app/webapp/app/migrate.sh b/server/app/webapp/app/migrate.sh new file mode 100755 index 0000000..4481759 --- /dev/null +++ b/server/app/webapp/app/migrate.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env sh +# +# Stands in for a real migration runner. The property that matters is the one +# asserted here: running it twice must be a no-op the second time, because an +# update run can be repeated and the playbook does not remember whether the last +# one finished. +set -eu + +echo "migrate: applying any pending migrations against ${DATABASE_URL%%:*}..." +echo "migrate: already up to date" diff --git a/server/app/webapp/app/serve.py b/server/app/webapp/app/serve.py new file mode 100644 index 0000000..1648fed --- /dev/null +++ b/server/app/webapp/app/serve.py @@ -0,0 +1,21 @@ +"""Minimal stand-in for an application server.""" + +import http.server +import os + +PORT = int(os.environ.get("PORT", "8080")) + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 - name fixed by the stdlib + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.end_headers() + self.wfile.write(b"webapp is running\n") + + def log_message(self, fmt, *args): + print(fmt % args, flush=True) + + +if __name__ == "__main__": + http.server.HTTPServer(("", PORT), Handler).serve_forever() diff --git a/server/app/webapp/docker-compose.migrate.yml b/server/app/webapp/docker-compose.migrate.yml new file mode 100644 index 0000000..6eb8d4e --- /dev/null +++ b/server/app/webapp/docker-compose.migrate.yml @@ -0,0 +1,39 @@ +# Maintenance services, layered on top of docker-compose.yml with a second -f. +# +# These are one-shot `run --rm` targets, never part of the running stack, which +# is why docker-compose.yml alone is what the playbook passes to `up` via +# stack_files. Bringing the stack up with both files would start them as +# long-running services. +services: + backup: + image: postgres:17-alpine + profiles: ["maintenance"] + depends_on: + db: + condition: service_healthy + environment: + PGPASSWORD: ${POSTGRES_PASSWORD:?set it in .env on the host} + volumes: + - ./backups:/backups + entrypoint: + - sh + - -c + - 'pg_dump -h db -U webapp webapp > /backups/webapp-$(date +%Y%m%dT%H%M%S).sql' + networks: + - backend + + migrate: + build: + context: ./app + image: example/webapp:local + profiles: ["maintenance"] + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgres://webapp:${POSTGRES_PASSWORD:?set it in .env on the host}@db:5432/webapp + # Migrations must be safe to run twice: an update run can be repeated, and + # the playbook does not track whether the last one finished. + command: ["/app/migrate.sh"] + networks: + - backend diff --git a/server/app/webapp/docker-compose.yml b/server/app/webapp/docker-compose.yml new file mode 100644 index 0000000..d3b2f2d --- /dev/null +++ b/server/app/webapp/docker-compose.yml @@ -0,0 +1,59 @@ +# A database-backed application: the stack that needs more than "sync and up". +# +# Every credential comes from .env, with no defaults. `${VAR:?message}` makes +# Compose refuse to start and say which variable is missing, which is strictly +# better than booting with an empty password. .env lives on the host and is never +# synced from this repository, so ansible/playbooks/webapp.yml asserts it is +# there before doing anything. +services: + db: + container_name: webapp-db + image: postgres:17-alpine + restart: unless-stopped + # ./pgdata is deliberately NOT pre-created by the playbook: Postgres refuses + # to start unless its data directory is 0700 or 0750, and the image gets that + # right on first start. See "Common mistakes" in skills/adding-a-stack. + volumes: + - ./pgdata:/var/lib/postgresql/data + environment: + POSTGRES_DB: webapp + POSTGRES_USER: webapp + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set it in .env on the host} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U webapp"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - backend + + web: + container_name: webapp + # Built from the payload rather than pulled. stack_build in the playbook + # decides when a rebuild happens: on an update run, not on every deploy. + build: + context: ./app + image: example/webapp:local + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgres://webapp:${POSTGRES_PASSWORD:?set it in .env on the host}@db:5432/webapp + SECRET_KEY: ${SECRET_KEY:?set it in .env on the host} + # Set by the role to stack_dest, so a path handed to a sibling container is + # the path on the host and not wherever the SSH session happened to land. + UPLOAD_HOST_DIR: ${PWD}/uploads + VIRTUAL_HOST: app.example.com + VIRTUAL_PORT: "8080" + LETSENCRYPT_HOST: app.example.com + volumes: + - ./uploads:/var/lib/webapp/uploads + networks: + - backend + - proxy-net + +networks: + backend: + proxy-net: + external: true diff --git a/server/edge/reverse-proxy/docker-compose.yml b/server/edge/reverse-proxy/docker-compose.yml new file mode 100644 index 0000000..9013c40 --- /dev/null +++ b/server/edge/reverse-proxy/docker-compose.yml @@ -0,0 +1,50 @@ +# The public entry point. nginx-proxy watches the Docker socket and writes a +# vhost for every container that sets VIRTUAL_HOST; acme-companion requests and +# renews the certificate for every LETSENCRYPT_HOST. +# +# Two things about this stack drive decisions elsewhere in the repository: +# +# - proxy-net is external. Every stack that wants to be reachable joins it, so +# something has to create it before any of them start. That is stack_networks +# in the role, and it is why site.yml deploys this stack first. +# - conf/, vhost/ and html/ are bind mounts holding plain files. Editing one +# changes nothing until nginx reloads, because Compose sees an unchanged +# compose file and does not recreate the container. That is what +# compose_stack_synced and the post_tasks reload are for. +services: + reverse-proxy: + container_name: reverse-proxy + image: nginxproxy/nginx-proxy:1.6 + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./proxy-data/conf:/etc/nginx/conf.d + - ./proxy-data/vhost:/etc/nginx/vhost.d + - ./proxy-data/html:/usr/share/nginx/html + - ./proxy-data/dhparam:/etc/nginx/dhparam + - ./proxy-data/certs:/etc/nginx/certs:ro + - /var/run/docker.sock:/tmp/docker.sock:ro + networks: + - proxy-net + + acme-companion: + container_name: reverse-proxy-acme + image: nginxproxy/acme-companion:2.4 + restart: unless-stopped + volumes_from: + - reverse-proxy + volumes: + - ./proxy-data/certs:/etc/nginx/certs + - ./proxy-data/acme:/etc/acme.sh + - /var/run/docker.sock:/var/run/docker.sock:ro + environment: + # Let's Encrypt sends expiry warnings here. + DEFAULT_EMAIL: admin@example.com + networks: + - proxy-net + +networks: + proxy-net: + external: true diff --git a/server/edge/reverse-proxy/proxy-data/conf/max-body-size.conf b/server/edge/reverse-proxy/proxy-data/conf/max-body-size.conf new file mode 100644 index 0000000..d2f4a5a --- /dev/null +++ b/server/edge/reverse-proxy/proxy-data/conf/max-body-size.conf @@ -0,0 +1,3 @@ +# Applies to every vhost. nginx defaults to 1M, which silently truncates ordinary +# file uploads. +client_max_body_size 100M; diff --git a/server/edge/reverse-proxy/proxy-data/vhost/app.example.com b/server/edge/reverse-proxy/proxy-data/vhost/app.example.com new file mode 100644 index 0000000..7c7d21b --- /dev/null +++ b/server/edge/reverse-proxy/proxy-data/vhost/app.example.com @@ -0,0 +1,23 @@ +# Per-vhost nginx configuration for app.example.com, included by nginx-proxy. +# +# A container that sets VIRTUAL_HOST gets its vhost generated automatically and +# needs nothing here. This file is for the cases generation cannot express: +# routes to a service that is not the vhost's main container, and access rules. + +# Anything not otherwise routed goes to the public site. +location = / { + return 301 https://app.example.com/tools/; +} + +# Internal-only: the metrics stack, reachable from the private network and +# nowhere else. Keep the deny rule directly beneath the allow rule — nginx takes +# the first match, so an allow added below a `deny all` does nothing. +location ^~ /internal/metrics/ { + allow 10.0.0.0/8; + deny all; + + proxy_pass http://prometheus:9090/internal/metrics/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +} diff --git a/server/shared/components/banner.html b/server/shared/components/banner.html new file mode 100644 index 0000000..c2b5468 --- /dev/null +++ b/server/shared/components/banner.html @@ -0,0 +1,9 @@ + + + +example.com +

Nothing is served at this address.

+

If you expected a service here, check the vhost configuration.

diff --git a/server/shared/metrics/.env.example b/server/shared/metrics/.env.example new file mode 100644 index 0000000..ac2f356 --- /dev/null +++ b/server/shared/metrics/.env.example @@ -0,0 +1,7 @@ +# Copy to .env ON THE HOST, not here. The compose_stack role excludes .env from +# every sync (stack_exclude), so a .env committed to this repository would be +# ignored, and a host's .env is never overwritten by a deploy. +# +# ssh deploy@app.example.com +# cd /srv/stacks/metrics && cp .env.example .env && $EDITOR .env +GF_SECURITY_ADMIN_PASSWORD= diff --git a/server/shared/metrics/docker-compose.yml b/server/shared/metrics/docker-compose.yml new file mode 100644 index 0000000..75020ef --- /dev/null +++ b/server/shared/metrics/docker-compose.yml @@ -0,0 +1,56 @@ +# Prometheus and Grafana, deployed to every host from this one payload. +# +# It lives under server/shared/ rather than server// because both hosts run +# exactly the same thing; ansible/playbooks/metrics.yml points stack_src here. +# Per-host differences (which networks exist, which extra directories to create) +# belong in the playbook, not in duplicated copies of this file. +# +# Both bind mounts below need a directory that exists with the right ownership +# before the container starts, and neither image will fix it for you: +# +# grafana-data the image runs as uid 472 and writes a SQLite database here +# prometheus-data the image runs as nobody (65534) +# +# The playbook pre-creates both. See "stack_dirs" in AGENTS.md for why that is +# the role's job and not a chown in pre_tasks. +services: + prometheus: + container_name: prometheus + image: prom/prometheus:v3.1.0 + restart: unless-stopped + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + # Served under a sub-path by the reverse proxy, so it has to generate + # absolute URLs that include it. + - --web.external-url=https://app.example.com/internal/metrics/ + - --web.route-prefix=/ + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./prometheus-data:/prometheus + networks: + - default + - proxy-net + + grafana: + container_name: grafana + image: grafana/grafana-oss:11.5.1 + restart: unless-stopped + networks: + - default + - proxy-net + environment: + VIRTUAL_HOST: dashboards.example.com + VIRTUAL_PORT: "3000" + LETSENCRYPT_HOST: dashboards.example.com + GF_SERVER_ROOT_URL: https://dashboards.example.com + # Compose fails the run if this is not set, rather than starting Grafana + # with a blank admin password. The value comes from the host's .env, which + # is never synced from this repository — see docs/secrets.md. + GF_SECURITY_ADMIN_PASSWORD: ${GF_SECURITY_ADMIN_PASSWORD:?set it in .env on the host} + volumes: + - ./grafana-data:/var/lib/grafana + +networks: + proxy-net: + external: true diff --git a/server/shared/metrics/prometheus.yml b/server/shared/metrics/prometheus.yml new file mode 100644 index 0000000..def0bb1 --- /dev/null +++ b/server/shared/metrics/prometheus.yml @@ -0,0 +1,15 @@ +# Bind-mounted read-only into the prometheus container. Prometheus re-reads it on +# SIGHUP, so metrics.yml reloads the container when this file changes rather than +# recreating it. +global: + scrape_interval: 30s + evaluation_interval: 30s + +scrape_configs: + - job_name: prometheus + static_configs: + - targets: ["localhost:9090"] + + - job_name: node + static_configs: + - targets: ["node-exporter:9100"]