Imported from driedel/make-it-static (
AGENTS.md). Install upstream withnpx skills add driedel/make-it-static. Copyright stays with the author.
AGENTS.md — Make it Static
This file is written for AI coding agents. It assumes no prior knowledge of the project. The human-facing documentation lives in README.md.
Developer commands
Rule: all tests and security scans must run inside Docker containers. Never run the test suite or install project dependencies directly on the host machine.
# First-time setup
docker network create make-it-static-network # REQUIRED — compose won't create it
cp .env.example .env
# Start dev (API + Worker + Redis + MinIO + nginx preview)
docker compose up --build
# Start prod (no MinIO)
docker compose -f docker-compose.prod.yml up -d --build
# Run tests inside a temporary container (worker image does not include api/ or tests/)
docker run --rm \
-v "$(pwd)/api:/app/api" \
-v "$(pwd)/worker:/app/worker" \
-v "$(pwd)/tests:/app/tests" \
-w /app \
make-it-static-worker \
bash -c "pip install --quiet -r api/requirements.txt -r worker/requirements.txt -r tests/requirements.txt && pytest tests/ -v --cov=api --cov=worker"
# Run security scans inside a temporary container
docker run --rm \
-v "$(pwd)/api:/app/api" \
-v "$(pwd)/worker:/app/worker" \
-w /app \
make-it-static-worker \
bash -c "pip install --quiet bandit pip-audit && bandit -r api worker && pip-audit -r api/requirements.txt -r worker/requirements.txt"
# Follow worker logs
docker compose logs -f worker
Project overview
Make it Static is a webhook-driven service that captures a rendered web page, turns it into a static site, optimizes the assets, and uploads the result to S3 (real AWS S3 or MinIO) with an optional CloudFront invalidation.
A client sends a signed POST /publish request with a URL and a post_id. The API validates the HMAC signature, enqueues a job in Redis, and an RQ worker executes the pipeline asynchronously.
Technology stack
- API: Python 3.12, FastAPI 0.115, Uvicorn, Redis, RQ
- Worker: Python 3.12, RQ, boto3, BeautifulSoup 4, rcssmin, rjsmin, minify-html, fonttools, Pillow
- External tools inside the worker container:
wget(website scraping) - Queue / cache: Redis 7
- Object storage (dev): MinIO (S3-compatible)
- Static preview (dev): nginx with an
envsubst-processed template - Container runtime: Docker / Docker Compose v2
- CI / CD: GitHub Actions (pytest + pylint, CodeQL security scan, optional EC2 deploy)
- Target Python version: 3.12 (the Docker images use
python:3.12-slim)
Repository layout
.
├── api/
│ ├── main.py # FastAPI application
│ ├── requirements.txt # API Python dependencies
│ └── Dockerfile # python:3.12-slim image
├── worker/
│ ├── worker.py # RQ worker entrypoint
│ ├── jobs.py # Pipeline orchestration (deploy_page)
│ ├── scrape.sh # wget wrapper
│ ├── postprocess.py # HTML cleanup and URL rewriting
│ ├── optimize.py # CSS/JS bundling + minification, image/font conversion
│ ├── deploy.py # S3 upload + CloudFront invalidation
│ ├── requirements.txt # Worker Python dependencies
│ └── Dockerfile # python:3.12-slim + wget image
├── tests/
│ ├── conftest.py # pytest path setup and default env vars
│ ├── test_api.py # FastAPI endpoint tests
│ ├── test_jobs.py # Pipeline / jobs.py tests
│ ├── test_optimize.py # optimize.py tests
│ ├── test_postprocess.py # postprocess.py tests
│ ├── test_integration.py # Integration tests for S3, CloudFront, conversions
│ └── requirements.txt # Test dependencies
├── nginx/
│ └── default.conf.template # Local preview nginx config
├── .github/workflows/
│ ├── tests.yml # pytest + pylint on push / PR
│ ├── codeql.yml # CodeQL security scan
│ └── deploy.yml # Manual EC2 deploy workflow
├── docker-compose.yml # Dev stack with Redis + API + Worker + MinIO + nginx preview
├── docker-compose.prod.yml # Production stack with Redis + API + Worker only
├── docker-compose.wordpress.yml # Example for adding the service to a WordPress project
├── .env.example # Environment variable template
├── .pylintrc # Pylint config (used by the lint step in tests.yml)
├── IAM_POLICY.json # Minimum AWS IAM policy for production
└── README.md # Human-facing documentation
Architecture
Client (CMS, CI/CD, curl, etc.)
│ POST /publish (HMAC-SHA256 signed)
▼
FastAPI (api/main.py)
│ enqueue
▼
Redis queue "deploys"
│
▼
RQ Worker (worker/worker.py)
│
├── scrape.sh wget captures HTML + static assets
├── jobs.py dynamic CDN / webpack / Elementor asset downloads
├── postprocess.py filename cleanup, URL rewriting, CMS artifact removal
├── optimize.py CSS/JS bundling + minification, image/font compression
├── deploy.py S3 upload with Content-Type / Cache-Control
└── deploy.py CloudFront invalidation
Key routing / naming conventions:
- The worker derives the hostname from the incoming
urlpayload, not from an environment variable. - S3 keys are prefixed with the hostname:
s3://{bucket}/{hostname}/path/to/index.html. - The CloudFront distribution for a site should use Origin Path
/{hostname}. - The local nginx preview uses
ORIGIN_HOSTfrom.envonly to route requests to/{bucket}/{ORIGIN_HOST}/....
Environment variables
Copy .env.example to .env and adjust:
| Variable | Purpose |
|---|---|
HMAC_SECRET |
Shared secret for signing webhook requests. Generate in production with openssl rand -hex 32. |
HMAC_MAX_SKEW |
Replay tolerance in seconds (default 300). |
REDIS_URL |
Redis connection URL, e.g. redis://redis:6379/0. |
AWS_ACCESS_KEY_ID |
AWS key; also used as MinIO root user in dev. |
AWS_SECRET_ACCESS_KEY |
AWS secret; also used as MinIO root password in dev. |
AWS_REGION |
AWS region (e.g. us-east-1). |
S3_BUCKET |
Target S3 bucket name. |
S3_ENDPOINT_URL |
Custom S3 endpoint. Leave empty + set S3_USE_PATH_STYLE=false for real AWS. |
S3_USE_PATH_STYLE |
true for MinIO, false for AWS virtual-hosted style. |
CLOUDFRONT_DISTRIBUTION_ID |
Global fallback distribution for invalidation. Optional per-request override via payload. |
CORS_ORIGINS |
Comma-separated list of allowed CORS origins. Defaults to * in dev. Restrict in production. |
ORIGIN_HOST |
Local preview only — hostname used by the dev nginx rewrite. |
SCRAPE_INTERNAL_HOSTS |
Comma-separated hosts that appear in scraped HTML and should be rewritten to relative paths. Dev-only. |
Build and run commands
Local development
-
Copy environment file:
cp .env.example .env -
Create the external Docker network (required by both compose files):
docker network create make-it-static-network -
Start the dev stack:
docker compose up --build -
Verify the API:
curl http://localhost:8123/health
Dev services:
- API: http://localhost:8123
- Local static preview: http://localhost:8080
- MinIO console: http://localhost:9001 (login
minioadmin/minioadminunless changed) - MinIO S3 API: http://localhost:9000
Production
Use docker-compose.prod.yml (no MinIO, API bound to localhost, TLS handled by Caddy/nginx/ALB):
docker compose -f docker-compose.prod.yml up -d
Production .env requirements:
HMAC_SECRETmust be strong and private.S3_ENDPOINT_URLshould be empty.S3_USE_PATH_STYLE=false.CLOUDFRONT_DISTRIBUTION_IDset or passed per-request.SCRAPE_INTERNAL_HOSTSshould be empty or removed.
Publishing images to Docker Hub
A separate workflow (.github/workflows/dockerhub.yml) builds and pushes the API and Worker images to Docker Hub:
daniloriedel/make-it-static-apidaniloriedel/make-it-static-worker
Triggers:
- Manual (
workflow_dispatch) — publisheslatest. - Git tags matching
v*.*.*— publisheslatest,vX.Y.Z, andvX.Y.
Required repository secrets:
DOCKERHUB_USERNAMEDOCKERHUB_TOKEN
The images are multi-arch (linux/amd64, linux/arm64) and are built from the existing api/Dockerfile and worker/Dockerfile — the project architecture is preserved.
Use docker-compose.wordpress.yml as a starting point for integrating the published images into another Docker Compose project (for example, alongside WordPress). The WordPress plugin should POST to http://make-it-static-api:8000/publish inside the Docker network.
Testing
Rule: all tests must run inside Docker. Do not install dependencies or run
pyteston the host machine.
The project uses pytest. Inside a temporary worker container, run:
docker run --rm \
-v "$(pwd)/api:/app/api" \
-v "$(pwd)/worker:/app/worker" \
-v "$(pwd)/tests:/app/tests" \
-w /app \
make-it-static-worker \
bash -c "pip install --quiet -r api/requirements.txt -r worker/requirements.txt -r tests/requirements.txt && pytest tests/ -v --cov=api --cov=worker --cov-report=term-missing --cov-report=xml"
The tests mock Redis/RQ and do not require a running Redis or Docker stack for the unit tests. They cover:
- HMAC authentication and payload validation (
test_api.py) - Job lifecycle endpoints (
test_api.py) - URL validation and SSRF mitigation (
test_api.py) - URL-to-prefix conversion and dynamic asset downloading (
test_jobs.py) - Optimization option flags (
test_jobs.py,test_optimize.py) - CSS/JS bundling and minification (
test_optimize.py) - Filename normalization, URL rewriting, and HTML absolutization (
test_postprocess.py) - S3 upload, CloudFront invalidation, font/image conversion, and subprocess behavior (
test_integration.py)
The worker container uses Python 3.12, which is the target version for this project. Running tests inside Docker avoids host interpreter issues.
Code style and conventions
- Follow the existing Python style in each module.
- Use type hints where they already appear (e.g.
list[str],pathlib.Path). - Keep functions focused and document non-obvious behavior in docstrings (the existing code does this extensively).
- Log pipeline progress with
print(..., flush=True)inside worker scripts; these lines become container logs. - Worker scripts are executed as subprocesses from
jobs.py; keep CLI interfaces stable (postprocess.py <dir> <host> [<cdn>...],optimize.py <dir> [--no-* flags]). - Prefer
pathlib.Pathfor filesystem operations. - When writing regexes for asset discovery, include comments explaining the matched pattern, as done for webpack chunks and Elementor assets.
Commit conventions
Use the conventional commit format:
type(scope): description
Examples:
feat(courses): add language filter
fix(i18n): correct page_content fallback for zh-CN
chore(deps): update pgx to v5.7.2
Common types: feat, fix, chore, refactor, docs, test.
Important: write all commit messages in English and do not include any AI signature or attribution (e.g., no "Generated by ...", "Signed-off-by AI", "Co-authored-by Assistant", model names, or similar markers).
Security considerations
HMAC_SECRETis the only authentication mechanism. Keep it secret and rotate it periodically.- The API uses CORS origins from
CORS_ORIGINS(defaults to*for development). Restrict to your client domain in production. - The
/publishendpoint validates:- Presence of
X-SignatureandX-Timestampheaders - HMAC-SHA256 signature over the raw request body
- Timestamp within
HMAC_MAX_SKEWto prevent replays - URL scheme is
httporhttps - URL hostname is not an internal/loopback/link-local/reserved IP address
- Presence of
scrape.shrejects WordPress admin, REST, feeds, pagination, search, and attachment URLs via--reject-regex.- Google Fonts domains are excluded from scraping because the CSS is User-Agent-specific; those links remain external and load from Google at runtime.
- AWS credentials in dev are reused as MinIO credentials. Change them before exposing MinIO.
- The production deploy workflow uses
environment: productionand manual trigger by default. - Never commit security scan reports (e.g.
bandit-report.txt,pip-audit-*.json) or private audit documents (e.g.SECURITY_AUDIT.md). Run scans inside Docker and keep reports local/private.
Deployment
A sample GitHub Actions workflow is at .github/workflows/deploy.yml. It is disabled by default (manual workflow_dispatch trigger). Required repository secrets:
EC2_HOSTEC2_USEREC2_SSH_KEYENV_FILE
The workflow runs tests, rsyncs the repository to /opt/make-it-static/ on the EC2 instance, and restarts the production Docker Compose stack.
Key gotchas
- External network:
make-it-static-networkmust exist beforedocker compose up. Compose declares it asexternal: trueand will fail if missing. - HMAC auth: All
/publishrequests requireX-Signature(hex HMAC-SHA256 of raw body) andX-Timestampheaders. Replay window: 300s (configurable viaHMAC_MAX_SKEW). - CORS: Open (
*) in dev. Restrict in production viaCORS_ORIGINS. .envis gitignored. Always copy from.env.examplefirst.- Dev uses MinIO at
http://minio:9000with path-style URLs. Prod: leaveS3_ENDPOINT_URLempty, setS3_USE_PATH_STYLE=false. - Deploy workflow (
.github/workflows/deploy.yml) is manual-only (workflow_dispatch). Tests must pass before deploy.
CI workflows
| Workflow | Trigger | What |
|---|---|---|
tests.yml |
push/PR to main |
pytest + coverage, pylint (blocking, 10.00/10 required) |
codeql.yml |
push/PR to main + weekly cron |
CodeQL security scan (SARIF → GitHub code scanning) |
dockerhub.yml |
manual dispatch + tags v*.*.* |
Build and push API + Worker images to Docker Hub |
deploy.yml |
manual dispatch | Run tests, rsync to EC2, restart via docker-compose.prod.yml |
Important agent notes
README.mdreferences atest-publish.shscript for manual testing. That script does not currently exist in the repository.- The worker derives the site hostname from the
urlfield of each request; do not assumeORIGIN_HOSTis used by worker logic. jobs.pydownloads extra assets in three specialized passes afterwgetand around postprocessing:- Dynamic CDN assets injected via JavaScript strings (
download_dynamic_cdn_assets) - Webpack lazy-loaded chunks (
download_webpack_chunks) - Elementor AssetsLoader runtime assets (
download_elementor_dynamic_assets)
- Dynamic CDN assets injected via JavaScript strings (
- Postprocessing intentionally does not use
wget --convert-linksbecause that mangled JavaScript template literals;postprocess.pyhandles all URL rewriting instead. - The project has no
pyproject.toml,setup.py,setup.cfg, orpackage.json. Dependency management is done through per-servicerequirements.txtfiles only.