Add the example stack payloads
The files copied to /srv/stacks/<stack>/ verbatim: compose files, vhosts, Prometheus configuration, the webapp image source. Payloads are data, never templated and never linted as Ansible content. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7b52e9af9e
commit
b76c76e3b0
@@ -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/<host>/<stack>/ a stack that runs on exactly one host
|
||||||
|
server/shared/<stack>/ 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.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>static-site</title>
|
||||||
|
<h1>static-site</h1>
|
||||||
|
<p>
|
||||||
|
Served from <code>server/app/static-site/html/</code> in the configuration
|
||||||
|
repository, synced to <code>/srv/stacks/static-site/html</code> on the
|
||||||
|
<code>app</code> host.
|
||||||
|
</p>
|
||||||
@@ -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 [email protected]
|
||||||
|
# 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=
|
||||||
@@ -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"]
|
||||||
Executable
+10
@@ -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"
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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: [email protected]
|
||||||
|
networks:
|
||||||
|
- proxy-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy-net:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Applies to every vhost. nginx defaults to 1M, which silently truncates ordinary
|
||||||
|
# file uploads.
|
||||||
|
client_max_body_size 100M;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!-- Served at / by the reverse proxy on every host, as a placeholder for
|
||||||
|
requests that match no vhost. Deployed by ansible/playbooks/banner.yml,
|
||||||
|
which drops this one file into the reverse-proxy stack's html directory on
|
||||||
|
every host rather than owning a stack of its own. -->
|
||||||
|
<!doctype html>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>example.com</title>
|
||||||
|
<h1>Nothing is served at this address.</h1>
|
||||||
|
<p>If you expected a service here, check the vhost configuration.</p>
|
||||||
@@ -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 [email protected]
|
||||||
|
# cd /srv/stacks/metrics && cp .env.example .env && $EDITOR .env
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD=
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Prometheus and Grafana, deployed to every host from this one payload.
|
||||||
|
#
|
||||||
|
# It lives under server/shared/ rather than server/<host>/ 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
|
||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user