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 = [
1671 "anyhow",1671 "anyhow",
1672 "cli",1672 "cli",
1673 "config",1673 "config",
1674 "git",
1674 "ssh",1675 "ssh",
1675 "store",1676 "store",
1676 "tokio",1677 "tokio",
Cargo.toml +7 −0
@@ -21,9 +21,16 @@ web = { path = "crates/web" }
21ssh = { path = "crates/ssh" }21ssh = { path = "crates/ssh" }
22config = { path = "crates/config" }22config = { path = "crates/config" }
23store = { path = "crates/store" }23store = { 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).
26git = { path = "crates/git", optional = true }
24anyhow = { workspace = true }27anyhow = { workspace = true }
25tokio = { workspace = true }28tokio = { workspace = true }
2629
30[features]
31# Statically link libgit2 (for the Docker image, which has no system libgit2).
32vendored = ["dep:git", "git/vendored"]
33
27[lints]34[lints]
28workspace = true35workspace = 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 ----
10FROM 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.
14RUN 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
18WORKDIR /build
19
20# Cache the dependency layer: build a stub crate graph first, then the real source.
21COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
22COPY crates ./crates
23COPY src ./src
24COPY migrations ./migrations
25COPY assets ./assets
26
27RUN cargo build --release --features vendored \
28 && /build/target/release/fabrica --version
29
30# ---- Runtime ----
31FROM 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.
36RUN 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
41RUN useradd --uid 10001 --system --home-dir /var/lib/fabrica --shell /usr/sbin/nologin fabrica
42
43COPY --from=builder /build/target/release/fabrica /usr/local/bin/fabrica
44RUN fabrica --version && git --version
45
46USER fabrica
47VOLUME /var/lib/fabrica
48EXPOSE 8080 2222
49
50HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
51 CMD ["fabrica", "doctor", "--quiet"]
52
53ENTRYPOINT ["/usr/bin/tini", "--", "fabrica"]
54CMD ["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
8services:
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
46volumes:
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 }
32# Random key generation for the SSH signature fixtures (PrivateKey::random).32# Random key generation for the SSH signature fixtures (PrivateKey::random).
33rand_core = { workspace = true }33rand_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).
39vendored = ["git2/vendored-libgit2"]
40
35[lints]41[lints]
36workspace = true42workspace = true
docs/deployment.md +102 −0
@@ -0,0 +1,102 @@
1# Deployment
2
3fabrica is one binary, one config file, one data directory. It needs the `git`
4binary (≥ 2.41) on `PATH` at runtime — every deployment path below guarantees it.
5
6## Configuration
7
8Copy `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
10key 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
12agenix / systemd-creds / Docker secrets). Validate with `fabrica config check`;
13inspect the merged result (secrets redacted) with `fabrica config show`.
14
15On 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
22database is SQLite; add `--profile postgres` to bring up Postgres and point
23`FABRICA__DATABASE__URL` at it.
24
25The image is multi-stage: the builder compiles with `--features vendored` (libgit2
26is 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
33A proxy in front **MUST NOT buffer** the git pack routes, or clones stall. For
34nginx:
35
36```nginx
37location / {
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
46Set `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
51The 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
79The 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
82configured), 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`)
88with `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
93There is no web sign-up. Create the first account from the CLI (the same binary):
94
95```sh
96fabrica --config /etc/fabrica/fabrica.toml user add alice alice@example.com --admin
97# prints an activation link; or set a password directly:
98fabrica --config /etc/fabrica/fabrica.toml user passwd alice
99fabrica --config /etc/fabrica/fabrica.toml key add --type ssh alice @~/.ssh/id_ed25519.pub
100```
101
102Run `fabrica doctor` to confirm git, the config, and the database are healthy.
flake.nix +116 −0
@@ -21,6 +21,122 @@
21 "aarch64-darwin"21 "aarch64-darwin"
22 ];22 ];
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
24 perSystem =140 perSystem =
25 { self'141 { self'
26 , pkgs142 , pkgs