fabrica

hanna/fabrica

feat(deploy): Dockerfile, compose, NixOS module, and deployment docs

736ee5a · hanna committed on 2026-07-25

- Dockerfile: multi-stage, no Nix. Builder installs pkg-config/libssl-dev/
  cmake/git and builds --features vendored (static libgit2); runtime is
  debian:stable-slim with git/tini/ca-certificates/libssl3, a non-root
  uid 10001 user, a git --version build-time check, and a doctor --quiet
  healthcheck under tini.
- compose.yml: fabrica + Postgres behind a profile (SQLite by default),
  named volumes, config by bind-mount plus FABRICA__* env, and a
  reverse-proxy note (proxy_buffering off on the pack routes).
- flake nixosModules.default: services.fabrica with a TOML-typed settings
  option, environmentFile secrets, openFirewall, and a hardened systemd
  unit (ProtectSystem=strict, NoNewPrivileges, CAP_NET_BIND_SERVICE only
  for privileged ports).
- git/fabrica gain the `vendored` feature (git2/vendored-libgit2) for the
  Docker image; the Nix build still uses the system libgit2.
- docs/deployment.md covers config, Docker, NixOS, the container, and the
  reverse-proxy caveat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
7 files changed · +348 −0UnifiedSplit
Cargo.lock +1 −0
@@ -1671,6 +1671,7 @@ dependencies = [
16711671 "anyhow",
16721672 "cli",
16731673 "config",
1674+ "git",
16741675 "ssh",
16751676 "store",
16761677 "tokio",
Cargo.toml +7 −0
@@ -21,9 +21,16 @@ web = { path = "crates/web" }
2121 ssh = { path = "crates/ssh" }
2222 config = { path = "crates/config" }
2323 store = { path = "crates/store" }
24+# Direct, optional dep only to expose git's `vendored` feature at the binary
25+# (feature unification then applies it to the transitive `git` too).
26+git = { path = "crates/git", optional = true }
2427 anyhow = { workspace = true }
2528 tokio = { workspace = true }
2629
30+[features]
31+# Statically link libgit2 (for the Docker image, which has no system libgit2).
32+vendored = ["dep:git", "git/vendored"]
33+
2734 [lints]
2835 workspace = true
2936
Dockerfile +54 −0
@@ -0,0 +1,54 @@
1+# This Source Code Form is subject to the terms of the Mozilla Public
2+# License, v. 2.0. If a copy of the MPL was not distributed with this
3+# file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+#
5+# Multi-stage, no Nix. The `vendored` feature statically links libgit2, so the
6+# runtime image needs no system libgit2. The Nix build (flake.nix) is the
7+# alternative; `packages.container` produces an equivalent image natively.
8+
9+# ---- Builder ----
10+FROM rustlang/rust:nightly-slim AS builder
11+
12+# pkg-config + libssl-dev for native-tls (mail); cmake for aws-lc-sys (ssh) and
13+# the vendored libgit2 build; git for build scripts.
14+RUN apt-get update && apt-get install -y --no-install-recommends \
15+ pkg-config libssl-dev cmake git ca-certificates \
16+ && rm -rf /var/lib/apt/lists/*
17+
18+WORKDIR /build
19+
20+# Cache the dependency layer: build a stub crate graph first, then the real source.
21+COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
22+COPY crates ./crates
23+COPY src ./src
24+COPY migrations ./migrations
25+COPY assets ./assets
26+
27+RUN cargo build --release --features vendored \
28+ && /build/target/release/fabrica --version
29+
30+# ---- Runtime ----
31+FROM debian:stable-slim
32+
33+# git is mandatory for pack transport; tini for signal handling; libssl3 for
34+# native-tls. Verify git is present at build time so a missing plumbing binary is
35+# a build failure, not a runtime surprise.
36+RUN apt-get update && apt-get install -y --no-install-recommends \
37+ git ca-certificates tini libssl3 \
38+ && rm -rf /var/lib/apt/lists/* \
39+ && git --version
40+
41+RUN useradd --uid 10001 --system --home-dir /var/lib/fabrica --shell /usr/sbin/nologin fabrica
42+
43+COPY --from=builder /build/target/release/fabrica /usr/local/bin/fabrica
44+RUN fabrica --version && git --version
45+
46+USER fabrica
47+VOLUME /var/lib/fabrica
48+EXPOSE 8080 2222
49+
50+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
51+ CMD ["fabrica", "doctor", "--quiet"]
52+
53+ENTRYPOINT ["/usr/bin/tini", "--", "fabrica"]
54+CMD ["serve"]
compose.yml +62 −0
@@ -0,0 +1,62 @@
1+# This Source Code Form is subject to the terms of the Mozilla Public
2+# License, v. 2.0. If a copy of the MPL was not distributed with this
3+# file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+#
5+# Default path is pure SQLite; Postgres is behind the `postgres` profile
6+# (`docker compose --profile postgres up`).
7+
8+services:
9+ fabrica:
10+ build: .
11+ # Or: image: ghcr.io/you/fabrica:latest
12+ restart: unless-stopped
13+ ports:
14+ - "8080:8080"
15+ # Map SSH. The advertised clone port is `ssh.clone_port` in the config,
16+ # which may differ from this published port (e.g. behind a forward).
17+ - "2222:2222"
18+ volumes:
19+ - data:/var/lib/fabrica
20+ - repos:/var/lib/fabrica/repos
21+ # Bind-mount the config; override a couple of keys via env below to
22+ # demonstrate both mechanisms.
23+ - ./fabrica.toml:/etc/fabrica/fabrica.toml:ro
24+ environment:
25+ # FABRICA__<SECTION>__<KEY> overrides the file (double underscores nest).
26+ FABRICA__INSTANCE__URL: "https://git.example.com"
27+ FABRICA__LOG__FORMAT: "json"
28+ healthcheck:
29+ test: ["CMD", "fabrica", "doctor", "--quiet"]
30+ interval: 30s
31+ timeout: 5s
32+
33+ postgres:
34+ image: postgres:16-alpine
35+ profiles: ["postgres"]
36+ restart: unless-stopped
37+ environment:
38+ POSTGRES_USER: fabrica
39+ POSTGRES_PASSWORD: fabrica
40+ POSTGRES_DB: fabrica
41+ volumes:
42+ - pg:/var/lib/postgresql/data
43+ # To use it, point the database URL at this service, e.g.:
44+ # FABRICA__DATABASE__URL: "postgres://fabrica:fabrica@postgres/fabrica"
45+
46+volumes:
47+ data:
48+ repos:
49+ pg:
50+
51+# --- Reverse proxy note ------------------------------------------------------
52+# A proxy in front MUST NOT buffer the git pack routes, or clones stall. For
53+# nginx:
54+#
55+# location / {
56+# proxy_pass http://fabrica:8080;
57+# proxy_http_version 1.1;
58+# # Critical for `/git-upload-pack` and `/info/refs`:
59+# proxy_buffering off;
60+# proxy_request_buffering off;
61+# proxy_read_timeout 3600s;
62+# }
crates/git/Cargo.toml +6 −0
@@ -32,5 +32,11 @@ tempfile = { workspace = true }
3232 # Random key generation for the SSH signature fixtures (PrivateKey::random).
3333 rand_core = { workspace = true }
3434
35+[features]
36+# Build libgit2 from source and link it statically, so the runtime image needs no
37+# system libgit2. Used by the Dockerfile (`--features vendored`); the Nix build
38+# uses the system libgit2 instead (LIBGIT2_NO_VENDOR=1).
39+vendored = ["git2/vendored-libgit2"]
40+
3541 [lints]
3642 workspace = true
docs/deployment.md +102 −0
@@ -0,0 +1,102 @@
1+# Deployment
2+
3+fabrica is one binary, one config file, one data directory. It needs the `git`
4+binary (≥ 2.41) on `PATH` at runtime — every deployment path below guarantees it.
5+
6+## Configuration
7+
8+Copy `fabrica.example.toml`, edit it, and point the binary at it with `--config`
9+(or place it at `/etc/fabrica/fabrica.toml` or `$XDG_CONFIG_HOME/fabrica/`). Every
10+key can be overridden by an environment variable `FABRICA__<SECTION>__<KEY>`
11+(double underscores nest), and any `*_file` key reads its value from a file (for
12+agenix / systemd-creds / Docker secrets). Validate with `fabrica config check`;
13+inspect the merged result (secrets redacted) with `fabrica config show`.
14+
15+On first run fabrica generates its HS256 secret (`auth.secret_file`, default
16+`{data_dir}/secret`, mode 0600) and its SSH host key (`ssh.host_key_path`).
17+
18+## Docker / Compose
19+
20+`docker compose up` builds the image (`Dockerfile`) and starts fabrica on `:8080`
21+(HTTP) and `:2222` (SSH), with named volumes for the data and repos. The default
22+database is SQLite; add `--profile postgres` to bring up Postgres and point
23+`FABRICA__DATABASE__URL` at it.
24+
25+The image is multi-stage: the builder compiles with `--features vendored` (libgit2
26+is statically linked), so the runtime (`debian:stable-slim`) needs only `git`,
27+`ca-certificates`, `tini`, and `libssl3`. It runs as a non-root user, `git
28+--version` is verified at build time, and the health check is `fabrica doctor
29+--quiet`.
30+
31+### Reverse proxy — read this
32+
33+A proxy in front **MUST NOT buffer** the git pack routes, or clones stall. For
34+nginx:
35+
36+```nginx
37+location / {
38+ proxy_pass http://127.0.0.1:8080;
39+ proxy_http_version 1.1;
40+ proxy_buffering off; # critical for /info/refs and /git-upload-pack
41+ proxy_request_buffering off;
42+ proxy_read_timeout 3600s;
43+}
44+```
45+
46+Set `server.behind_proxy = true` so `X-Forwarded-For`/`-Proto` are trusted, and
47+`auth.cookie_secure = true` once you terminate TLS at the proxy.
48+
49+## NixOS
50+
51+The flake exposes `nixosModules.default`:
52+
53+```nix
54+{
55+ inputs.fabrica.url = "github:you/fabrica";
56+ # …
57+ nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
58+ modules = [
59+ fabrica.nixosModules.default
60+ {
61+ services.fabrica = {
62+ enable = true;
63+ openFirewall = true;
64+ environmentFile = "/run/secrets/fabrica.env"; # FABRICA__* secrets
65+ settings = {
66+ instance.url = "https://git.example.com";
67+ instance.name = "our forge";
68+ server.port = 8080;
69+ ssh.port = 2222;
70+ database.url = "sqlite:///var/lib/fabrica/fabrica.db";
71+ };
72+ };
73+ }
74+ ];
75+ };
76+}
77+```
78+
79+The module renders `settings` to a TOML file, runs a hardened `systemd` unit
80+(`DynamicUser` off, `StateDirectory=fabrica`, `ProtectSystem=strict`,
81+`NoNewPrivileges`, `CAP_NET_BIND_SERVICE` only when a privileged port is
82+configured), and opens the firewall when asked. The package wraps the binary with
83+`git` on `PATH`.
84+
85+## Nix container
86+
87+`nix build .#container` produces an OCI image (`dockerTools.buildLayeredImage`)
88+with `git` and `ca-certificates` in the closure — a Nix-native alternative to the
89+`Dockerfile`. Load it with `docker load < result`.
90+
91+## First user
92+
93+There is no web sign-up. Create the first account from the CLI (the same binary):
94+
95+```sh
96+fabrica --config /etc/fabrica/fabrica.toml user add alice alice@example.com --admin
97+# prints an activation link; or set a password directly:
98+fabrica --config /etc/fabrica/fabrica.toml user passwd alice
99+fabrica --config /etc/fabrica/fabrica.toml key add --type ssh alice @~/.ssh/id_ed25519.pub
100+```
101+
102+Run `fabrica doctor` to confirm git, the config, and the database are healthy.
flake.nix +116 −0
@@ -21,6 +21,122 @@
2121 "aarch64-darwin"
2222 ];
2323
24+ # NixOS module: services.fabrica. A hardened systemd unit, a TOML-typed
25+ # `settings` option, secrets via `environmentFile`, and optional firewall
26+ # opening. `git` is wrapped onto the service PATH by the package itself.
27+ flake.nixosModules.default =
28+ { config, lib, pkgs, ... }:
29+ let
30+ cfg = config.services.fabrica;
31+ format = pkgs.formats.toml { };
32+ configFile = format.generate "fabrica.toml" cfg.settings;
33+ in
34+ {
35+ options.services.fabrica = {
36+ enable = lib.mkEnableOption "the fabrica git server";
37+
38+ package = lib.mkOption {
39+ type = lib.types.package;
40+ default = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.default;
41+ description = "The fabrica package to run.";
42+ };
43+
44+ user = lib.mkOption {
45+ type = lib.types.str;
46+ default = "fabrica";
47+ description = "User the service runs as.";
48+ };
49+
50+ group = lib.mkOption {
51+ type = lib.types.str;
52+ default = "fabrica";
53+ description = "Group the service runs as.";
54+ };
55+
56+ dataDir = lib.mkOption {
57+ type = lib.types.path;
58+ default = "/var/lib/fabrica";
59+ description = "State directory (repos, database, host key, secret).";
60+ };
61+
62+ environmentFile = lib.mkOption {
63+ type = lib.types.nullOr lib.types.path;
64+ default = null;
65+ description = ''
66+ Path to an EnvironmentFile with secrets as FABRICA__* variables
67+ (e.g. agenix/systemd-creds output). Kept out of the Nix store.
68+ '';
69+ };
70+
71+ openFirewall = lib.mkOption {
72+ type = lib.types.bool;
73+ default = false;
74+ description = "Open the HTTP and SSH ports in the firewall.";
75+ };
76+
77+ settings = lib.mkOption {
78+ inherit (format) type;
79+ default = { };
80+ description = "Configuration rendered to fabrica.toml.";
81+ example = {
82+ instance.url = "https://git.example.com";
83+ server.port = 8080;
84+ ssh.port = 2222;
85+ };
86+ };
87+ };
88+
89+ config = lib.mkIf cfg.enable {
90+ users.users.${cfg.user} = {
91+ isSystemUser = true;
92+ group = cfg.group;
93+ home = cfg.dataDir;
94+ };
95+ users.groups.${cfg.group} = { };
96+
97+ networking.firewall = lib.mkIf cfg.openFirewall {
98+ allowedTCPPorts = [
99+ (cfg.settings.server.port or 8080)
100+ (cfg.settings.ssh.port or 2222)
101+ ];
102+ };
103+
104+ systemd.services.fabrica = {
105+ description = "fabrica git server";
106+ wantedBy = [ "multi-user.target" ];
107+ after = [ "network.target" ];
108+ serviceConfig = {
109+ ExecStart = "${cfg.package}/bin/fabrica --config ${configFile} serve";
110+ User = cfg.user;
111+ Group = cfg.group;
112+ StateDirectory = "fabrica";
113+ WorkingDirectory = cfg.dataDir;
114+ Restart = "on-failure";
115+ EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile;
116+
117+ # Hardening.
118+ DynamicUser = false;
119+ NoNewPrivileges = true;
120+ ProtectSystem = "strict";
121+ ProtectHome = true;
122+ PrivateTmp = true;
123+ PrivateDevices = true;
124+ ProtectKernelTunables = true;
125+ ProtectKernelModules = true;
126+ ProtectControlGroups = true;
127+ RestrictSUIDSGID = true;
128+ LockPersonality = true;
129+ ReadWritePaths = [ cfg.dataDir ];
130+ # Only if a privileged port is configured.
131+ AmbientCapabilities = lib.mkIf
132+ ((cfg.settings.server.port or 8080) < 1024
133+ || (cfg.settings.ssh.port or 2222) < 1024)
134+ [ "CAP_NET_BIND_SERVICE" ];
135+ };
136+ };
137+ };
138+ };
139+
24140 perSystem =
25141 { self'
26142 , pkgs