Skip to content

Commit 27198ea

Browse files
feat(docker): supported single-host self-hosting stack and install guide (#1395)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c887a30 commit 27198ea

22 files changed

Lines changed: 904 additions & 25 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"hephaestus": minor
3+
---
4+
5+
You can now self-host Hephaestus on a single Linux server. `docker/self-host/` adds one
6+
supported Docker Compose stack — application server, webhook receiver, PostgreSQL and NATS
7+
behind a TLS reverse proxy — that reuses the maintainers' service definitions, so it has no
8+
second copy to fall out of date. Follow the new [install guide](https://ls1intum.github.io/Hephaestus/admin/install);
9+
GitHub App setup, manual webhook creation, and backup/restore each have a companion page.
10+
11+
Existing deployments are unaffected: the reference Compose files are unchanged apart from two
12+
safe additions — the NATS JetStream limits become overridable (defaults unchanged), and a
13+
release-pin sanity check that rejected every valid pin is fixed.

.changeset/fix-release-pin-grep.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"hephaestus": patch
3+
---
4+
5+
Fixes the release-image pin check rejecting every valid pinned digest, which stopped the
6+
application server from starting on a fresh deploy that enforces the digest pin.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
name: Validate Compose
2+
3+
# The self-hosted stack (docker/self-host/) composes the reference deployment's
4+
# service definitions via `include`, so a change to docker/compose.*.yaml can
5+
# break a stranger's install without touching a single self-host file. Rendering
6+
# both stacks here turns interpolation- and merge-level breakage into a red check
7+
# instead of a bad first boot. (Semantic breakage — a renamed service silently
8+
# joining the stack, an inherited runtime bug — still needs a real boot.)
9+
#
10+
# Runs on every PR (not just docker/ changes) so it can be a required check,
11+
# matching how verify-changesets.yml is wired. It is cheap: only `docker compose
12+
# config`, no image pulls.
13+
14+
on:
15+
pull_request:
16+
push:
17+
branches: [main]
18+
19+
permissions:
20+
contents: read
21+
22+
jobs:
23+
validate:
24+
name: "Render compose stacks"
25+
runs-on: ubuntu-latest
26+
steps:
27+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
28+
29+
- name: Render the self-hosted stack
30+
working-directory: docker/self-host
31+
run: |
32+
set -euo pipefail
33+
# Fill the required values the way an operator would; `docker compose
34+
# config` fails on any variable the stack requires but .env.example
35+
# does not carry, which is exactly the drift we want to catch.
36+
cp .env.example .env
37+
sed -i \
38+
-e 's|^APP_HOSTNAME=$|APP_HOSTNAME=hephaestus.example.com|' \
39+
-e 's|^ACME_EMAIL=$|ACME_EMAIL=operator@example.com|' \
40+
-e 's|^POSTGRES_PASSWORD=$|POSTGRES_PASSWORD=ci-not-a-real-password|' \
41+
-e 's|^HEPHAESTUS_SECURITY_ENCRYPTION_KEY=$|HEPHAESTUS_SECURITY_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef|' \
42+
-e 's|^HEPHAESTUS_AUTH_STATE_COOKIE_KEY=$|HEPHAESTUS_AUTH_STATE_COOKIE_KEY=Y2ktbm90LWEtcmVhbC1zdGF0ZS1jb29raWUta2V5|' \
43+
-e 's|^WEBHOOK_SECRET=$|WEBHOOK_SECRET=ci000000000000000000000000000000000|' \
44+
.env
45+
docker compose config > /dev/null
46+
echo "Rendered services:"
47+
docker compose config --services | sort
48+
49+
- name: Fail on unset variables
50+
working-directory: docker/self-host
51+
run: |
52+
set -euo pipefail
53+
# `config` only warns about variables missing from .env; a warning here
54+
# means .env.example has fallen behind the stack it renders.
55+
if docker compose config 2>&1 >/dev/null | grep "variable is not set"; then
56+
echo "::error::docker/self-host/.env.example is missing variables the stack references (see warnings above)"
57+
exit 1
58+
fi
59+
60+
- name: Assert the merged stack matches single-host intent
61+
working-directory: docker/self-host
62+
run: |
63+
set -euo pipefail
64+
services=$(docker compose config --services)
65+
for unwanted in application-worker maintenance; do
66+
grep -qx "$unwanted" <<< "$services" && {
67+
echo "::error::'$unwanted' is a reference-deployment service and must not run on a single host"; exit 1; } || true
68+
done
69+
for required in application-server webhook-server postgres nats-server webapp reverse-proxy; do
70+
grep -qx "$required" <<< "$services" || {
71+
echo "::error::'$required' is missing from the self-hosted stack"; exit 1; }
72+
done
73+
# Compose 2.21-2.23 parse `!override` but silently ignore it, which would
74+
# publish the reference's dashboard port and keep the maintainers' ACME
75+
# email. Assert the merged result rather than trust the runner's version.
76+
rendered=$(docker compose config)
77+
grep -q "admin@tum.de" <<< "$rendered" && {
78+
echo "::error::the maintainers' ACME email survived the override — Compose is too old to honour !override"; exit 1; } || true
79+
published=$(docker compose config --format json \
80+
| jq -r '.services["reverse-proxy"].ports[].published' | sort -n | tr '\n' ' ')
81+
[ "$published" = "80 443 " ] || {
82+
echo "::error::reverse-proxy publishes '$published', expected '80 443 ' — !override was ignored"; exit 1; }
83+
84+
- name: Assert the pinned release version has not drifted
85+
run: |
86+
set -euo pipefail
87+
# scripts/sync-selfhost-version.mjs keeps these equal to the release on
88+
# every Version PR; a mismatch means someone edited one by hand.
89+
pkg=$(node -p "require('./package.json').version")
90+
env_tag=$(grep -m1 '^IMAGE_TAG=' docker/self-host/.env.example | cut -d= -f2)
91+
[ "$pkg" = "$env_tag" ] || {
92+
echo "::error::IMAGE_TAG=$env_tag in .env.example != package.json $pkg (run scripts/sync-selfhost-version.mjs)"; exit 1; }
93+
94+
- name: Render the reference deployment
95+
working-directory: docker
96+
run: |
97+
set -euo pipefail
98+
# Values mirror what the deploy workflow supplies; only proves the files
99+
# still render, not that they are correct for production.
100+
docker compose \
101+
-f compose.proxy.yaml -f compose.core.yaml -f compose.app.yaml \
102+
--env-file self-host/.env config --quiet

.github/workflows/version-pr.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ jobs:
4949
- name: Maintain Version PR
5050
uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0
5151
with:
52-
version: pnpm changeset version
52+
# Also syncs the self-host IMAGE_TAG / install-guide version to the new
53+
# release (scripts/sync-selfhost-version.mjs), so those literals never
54+
# drift. The changesets action commits whatever the version step writes.
55+
version: pnpm run changeset:version
5356
# No `publish:` — tagging, GitHub Release, and deploys are release.yml's
5457
# job (it uses `v`-prefixed tags to stay consistent with existing tags,
5558
# docker IMAGE_TAG, and the release-pin asset; `changeset tag` would emit

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ celerybeat.pid
130130
.env
131131
.venv
132132
env/
133+
134+
# Self-host: Traefik writes cert material here inside the operator's checkout
135+
docker/self-host/letsencrypt/
133136
venv/
134137
ENV/
135138
env.bak/

INSTALL.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Installing Hephaestus
2+
3+
There is exactly **one supported install**: the Docker Compose stack in
4+
[`docker/self-host/`](docker/self-host/) on one 64-bit Linux host
5+
(4 vCPUs / 8 GB RAM / 40 GB SSD recommended). Other setups may work but are unsupported.
6+
7+
**→ Follow the install guide: <https://ls1intum.github.io/Hephaestus/admin/install>**
8+
9+
For contributor/development setup, see the
10+
[contributor docs](https://ls1intum.github.io/Hephaestus/contributor/local-development) instead.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ UI component docs: [Storybook](https://main--66a8981a27ced8fef3190d41.chromatic.
5151

5252
### Setup
5353

54-
Read the [local development guide](https://ls1intum.github.io/Hephaestus/contributor/local-development) for the Spring Boot application server (with the in-process Pi mentor agent) and the React client in `webapp`.
54+
- **Self-hosting:** follow the [install guide](https://ls1intum.github.io/Hephaestus/admin/install) ([INSTALL.md](INSTALL.md)) — one supported Docker Compose path.
55+
- **Development:** read the [local development guide](https://ls1intum.github.io/Hephaestus/contributor/local-development) for the Spring Boot application server (with the in-process Pi mentor agent) and the React client in `webapp`.
5556

5657
## Contributing
5758

docker/compose.app.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ services:
7878
--certificate-identity 'https://github.com/ls1intum/Hephaestus/.github/workflows/release.yml@refs/heads/main' \
7979
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
8080
"$$TMP/pin.yaml"
81-
grep -Eq '^[[:space:]]+reference:[[:space:]]+[a-z0-9][a-z0-9.\-_/:]+@sha256:[a-f0-9]{64}$$' "$$TMP/pin.yaml" \
81+
grep -Eq '^[[:space:]]+reference:[[:space:]]+[a-z0-9][a-z0-9._/:-]+@sha256:[a-f0-9]{64}$$' "$$TMP/pin.yaml" \
8282
|| { echo "pin asset is missing a digest-pinned reference" >&2; exit 1; }
8383
install -m 0644 "$$TMP/pin.yaml" /pin/release-pin.yaml.new
8484
mv /pin/release-pin.yaml.new /pin/release-pin.yaml

docker/compose.core.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,6 @@ configs:
143143
144144
jetstream {
145145
store_dir: "/data"
146-
max_mem: 4G
147-
max_file: 50G
146+
max_mem: ${NATS_JS_MAX_MEM:-4G}
147+
max_file: ${NATS_JS_MAX_FILE:-50G}
148148
}

docker/self-host/.env.example

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# =============================================================================
2+
# Hephaestus self-hosted — environment
3+
# =============================================================================
4+
#
5+
# cp .env.example .env # then fill in every REQUIRED value below
6+
# docker compose up -d
7+
#
8+
# Full guide (read it first): https://ls1intum.github.io/Hephaestus/admin/install
9+
#
10+
# Hardware honesty: 4 vCPUs / 8 GB RAM / 40 GB SSD recommended. The stack runs
11+
# two JVM services plus Postgres and NATS; 4 GB RAM is the absolute floor and
12+
# only without AI practice review. Each concurrent AI review sandbox may use
13+
# up to 4 GiB on top.
14+
# =============================================================================
15+
16+
# --- General (REQUIRED) ------------------------------------------------------
17+
18+
# Public hostname the instance is served on (DNS A record -> this host).
19+
# No scheme, no trailing slash. Example: hephaestus.example.com
20+
APP_HOSTNAME=
21+
22+
# Release to run: an exact version, no leading `v`, never `latest`.
23+
# Must match the release you checked out. See the install guide.
24+
IMAGE_TAG=0.73.2
25+
26+
# Email for Let's Encrypt certificate-expiry notices.
27+
ACME_EMAIL=
28+
29+
# --- Secrets (REQUIRED — generate once, then never change) -------------------
30+
31+
# Database password. Applied only when the data volume is first initialized.
32+
# Generate: openssl rand -hex 16
33+
POSTGRES_PASSWORD=
34+
35+
# AES-256 key encrypting credentials at rest (and sealing the JWT signing key).
36+
# EXACTLY 32 characters. Losing or changing it makes every stored provider
37+
# token unreadable — treat it like the database itself and back it up.
38+
# Generate: openssl rand -base64 24 | cut -c1-32
39+
HEPHAESTUS_SECURITY_ENCRYPTION_KEY=
40+
41+
# Base64-encoded 32-byte AES key sealing the short-lived OAuth state cookies.
42+
# Generate: openssl rand -base64 32
43+
HEPHAESTUS_AUTH_STATE_COOKIE_KEY=
44+
45+
# Shared secret verifying inbound GitHub/GitLab webhooks (min 32 chars).
46+
# You will enter this same value on the GitHub side — see the install guide.
47+
# Generate: openssl rand -hex 32
48+
WEBHOOK_SECRET=
49+
50+
# --- Login (at least one provider REQUIRED, or nobody can sign in) -----------
51+
52+
# GitHub OAuth App (https://github.com/settings/developers -> "New OAuth App").
53+
# Authorization callback URL: https://<APP_HOSTNAME>/api/login/oauth2/code/github
54+
# Named GH_OAUTH_* (not GITHUB_OAUTH_*) because GitHub Actions reserves the
55+
# GITHUB_ prefix; the container still presents the right name to the server.
56+
GH_OAUTH_CLIENT_ID=
57+
GH_OAUTH_CLIENT_SECRET=
58+
59+
# GitLab OAuth application (optional; gitlab.com or self-hosted; scope: read_user).
60+
# Callback: https://<APP_HOSTNAME>/api/login/oauth2/code/gitlab
61+
#GITLAB_OAUTH_CLIENT_ID=
62+
#GITLAB_OAUTH_CLIENT_SECRET=
63+
#GITLAB_OAUTH_BASE_URL=https://gitlab.com
64+
#GITLAB_OAUTH_DISPLAY_NAME=GitLab
65+
66+
# --- First admin (REQUIRED — set BEFORE first boot) ---------------------------
67+
68+
# Who becomes instance admin on their first sign-in. Comma-separated
69+
# <provider>:@<username> or <provider>:<numeric-id>, e.g. github:@octocat
70+
# On public github.com prefer the numeric id (from
71+
# https://api.github.com/users/<login>) — handles can be reclaimed.
72+
HEPHAESTUS_AUTH_BOOTSTRAP_ADMINS=
73+
74+
# Break-glass fallback: enables one-time POST /auth/bootstrap-admin while no
75+
# admin exists. Leave unset unless you need it; unset again after use.
76+
#HEPHAESTUS_AUTH_BOOTSTRAP_TOKEN=
77+
78+
# --- GitHub App (optional — PAT-only mode works without any of these) --------
79+
# Needed for posting AI review feedback back to GitHub and higher rate limits.
80+
# Setup guide: https://ls1intum.github.io/Hephaestus/admin/github-integration
81+
GH_APP_PRIVATE_KEY=
82+
#GH_APP_ID=
83+
#GH_APP_INSTALLATION_URL=
84+
85+
# Legacy GitHub token used for contributor metadata only. Leave blank.
86+
GH_AUTH_TOKEN=
87+
88+
# --- Optional integrations ----------------------------------------------------
89+
# GitLab sync, Slack, Outline, AI practice review: enable by uncommenting here.
90+
# See https://ls1intum.github.io/Hephaestus/admin/production-setup for each bundle.
91+
#GITLAB_ENABLED=false
92+
#GITLAB_WORKSPACE_CREATION=false
93+
# Default GitLab instance for sync + workspace creation (compose otherwise defaults
94+
# this to the maintainers' gitlab.lrz.de). Point it at your instance.
95+
#GITLAB_DEFAULT_SERVER_URL=https://gitlab.com
96+
#HEPHAESTUS_INTEGRATION_SLACK_ENABLED=false
97+
#HEPHAESTUS_INTEGRATION_SLACK_CLIENT_ID=
98+
#HEPHAESTUS_INTEGRATION_SLACK_CLIENT_SECRET=
99+
#HEPHAESTUS_INTEGRATION_SLACK_SIGNING_SECRET=
100+
#HEPHAESTUS_INTEGRATION_OUTLINE_ENABLED=false
101+
102+
# AI practice review bundle (enable all three together + an LLM upstream), plus
103+
# a model and API key per workspace in the UI. Budget ~4 GiB RAM per sandbox.
104+
#AGENT_NATS_ENABLED=false
105+
#GIT_CHECKOUT_ENABLED=false
106+
#PRACTICE_REVIEW_FOR_ALL=false
107+
#SANDBOX_MAX_CONCURRENT=1
108+
#LLM_PROXY_OPENAI_URL=https://api.openai.com
109+
110+
# --- Misc (optional) -----------------------------------------------------------
111+
112+
# Imprint/privacy pages (required for public instances in e.g. Germany):
113+
# see https://ls1intum.github.io/Hephaestus/admin/legal-pages
114+
#LEGAL_PROFILE=
115+
116+
# Sentry error tracking. Leave blank to disable.
117+
SENTRY_DSN=
118+
SENTRY_ENVIRONMENT=production
119+
# PostHog product analytics — disabled unless POSTHOG_ENABLED=true; the keys below
120+
# do nothing on their own. Leave the two keys present (blank is fine) either way.
121+
#POSTHOG_ENABLED=false
122+
POSTHOG_PROJECT_API_KEY=
123+
POSTHOG_API_HOST=
124+
125+
# NATS JetStream limits, sized for a single host (reference deployment uses 4G/50G).
126+
NATS_JS_MAX_MEM=1G
127+
NATS_JS_MAX_FILE=10G
128+
129+
# Proxy-trust regex; the default matches reverse-proxy's fixed IP (172.29.47.2).
130+
# Only set when you changed that IP or the subnet in compose.single-host.yaml.
131+
#HEPHAESTUS_TRUSTED_PROXIES=172\.29\.47\.2

0 commit comments

Comments
 (0)