diff --git a/.dockerignore b/.dockerignore index a2140ff..de5ff91 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ .git +.env bin/ .editorconfig .vscode/ diff --git a/README.md b/README.md index 8f7f109..f189bc0 100644 --- a/README.md +++ b/README.md @@ -219,39 +219,29 @@ This recipe serves plain HTTP, so `UPAAS_PLAINTEXT_HTTP=true` is required for setup and every other form to pass the CSRF origin check. Behind a TLS-terminating reverse proxy, drop that line. -### Docker Compose +### Deploying with Docker Compose -```yaml -services: - upaas: - build: . - restart: unless-stopped - ports: - - "8080:8080" - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ${HOST_DATA_DIR}:/var/lib/upaas - environment: - - UPAAS_HOST_DATA_DIR=${HOST_DATA_DIR} - # Set when serving plain HTTP (no TLS-terminating proxy); drop behind one - - UPAAS_PLAINTEXT_HTTP=true - # Optional: uncomment to enable debug logging - # - DEBUG=true - # Optional: Sentry error reporting - # - SENTRY_DSN=https://... - # Optional: Prometheus metrics auth - # - METRICS_USERNAME=prometheus - # - METRICS_PASSWORD=secret +[`docker-compose.yml`](docker-compose.yml) builds the image from this repo and +runs it with the Docker socket and the data directory mounted. It reads its +settings from a `.env` file next to it, which needs at least: + +```bash +HOST_DATA_DIR=/srv/upaas/data ``` -**Important**: You **must** set `HOST_DATA_DIR` to an **absolute path** on the -host before running `docker compose up`. This value is bind-mounted into the -container and passed as `UPAAS_HOST_DATA_DIR` so that Docker bind mounts during -builds resolve correctly. Relative paths (e.g. `./data`) will break container -builds because the Docker daemon resolves paths relative to the host, not the -container. +Other settings from [Configuration](#configuration) go in the same file, except +`PORT`: the compose file sets it to 8080, overriding `.env`, to match its port +mapping and healthcheck. Then run `docker compose up -d` from the repo root; +`docker compose ps` shows the container as healthy once `/health` answers. -Example: `HOST_DATA_DIR=/srv/upaas/data docker compose up -d` +**Important**: `HOST_DATA_DIR` **must** be an **absolute path** on the host. It +is bind-mounted into the container and passed as `UPAAS_HOST_DATA_DIR` so that +Docker bind mounts during builds resolve correctly, because the Docker daemon +resolves paths on the host, not in the container. upaas refuses to start when +`UPAAS_HOST_DATA_DIR` is a relative path such as `./data`. + +The port is published on `127.0.0.1:8080` only, for a TLS-terminating reverse +proxy in front of it. Leave `UPAAS_PLAINTEXT_HTTP` unset behind that proxy. Apps are built with BuildKit, so the stages of a multi-stage build are kept in Docker's build cache rather than as untagged images. Docker Engine 28.2 and diff --git a/TODO.md b/TODO.md index 6b6e056..50e289f 100644 --- a/TODO.md +++ b/TODO.md @@ -20,6 +20,12 @@ regress. # Completed Steps +- 2026-09-28: Added `docker-compose.yml` for deploying upaas: settings from + `.env`, the port published on `127.0.0.1` only for a TLS proxy in front, and a + healthcheck against `/health`; the README's plain-HTTP Compose example is + replaced by a short deploy section. upaas now refuses to start when + `UPAAS_HOST_DATA_DIR` is set to a relative path (#223). + - 2026-09-23: Apps are now built with BuildKit, so the stages of a multi-stage build stay in Docker's size-limited build cache instead of piling up as untagged images; build progress is still written to the deployment log as diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4d9282f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +# Runs upaas. Put settings in .env next to this file; see "Deploying with +# Docker Compose" in README.md. +services: + upaas: + build: . + restart: unless-stopped + # Every line of .env is passed to upaas as an environment variable. + env_file: .env + environment: + # Overrides any PORT in .env, so upaas listens where the port mapping + # and healthcheck below expect it. + PORT: "8080" + # The Docker daemon resolves app bind mounts on the host, so upaas must + # know the host path of its data directory. + UPAAS_HOST_DATA_DIR: ${HOST_DATA_DIR:?set HOST_DATA_DIR in .env to an absolute host path} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ${HOST_DATA_DIR:?set HOST_DATA_DIR in .env to an absolute host path}:/var/lib/upaas + # Loopback only, for a TLS-terminating reverse proxy in front. Leave + # UPAAS_PLAINTEXT_HTTP unset behind that proxy. + ports: + - "127.0.0.1:8080:8080" + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/health"] + interval: 30s + timeout: 5s + retries: 3 diff --git a/internal/config/config.go b/internal/config/config.go index 6d42b14..56e23c6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,6 +32,12 @@ const ( filePermissions = 0o600 ) +// errHostDataDirNotAbsolute is returned when UPAAS_HOST_DATA_DIR is set to a +// relative path, which the Docker daemon cannot resolve for app bind mounts. +var errHostDataDirNotAbsolute = errors.New( + "UPAAS_HOST_DATA_DIR must be an absolute path", +) + // Params contains dependencies for Config. type Params struct { fx.In @@ -124,6 +130,10 @@ func buildConfig(log *slog.Logger, params *Params) (*Config, error) { dataDir := viper.GetString("DATA_DIR") hostDataDir := viper.GetString("HOST_DATA_DIR") + if hostDataDir != "" && !filepath.IsAbs(hostDataDir) { + return nil, fmt.Errorf("%w, got %q", errHostDataDirNotAbsolute, hostDataDir) + } + if hostDataDir == "" { hostDataDir = dataDir } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..eaf34fb --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,33 @@ +package config //nolint:testpackage // tests unexported buildConfig + +import ( + "errors" + "log/slog" + "testing" +) + +func TestBuildConfigRejectsRelativeHostDataDir(t *testing.T) { + t.Setenv("UPAAS_HOST_DATA_DIR", "./data") + setupViper("upaas") + + _, err := buildConfig(slog.Default(), &Params{}) + if !errors.Is(err, errHostDataDirNotAbsolute) { + t.Fatalf("expected errHostDataDirNotAbsolute, got %v", err) + } +} + +func TestBuildConfigHostDataDirDefaultsToDataDir(t *testing.T) { + t.Setenv("UPAAS_DATA_DIR", "./data") + t.Setenv("UPAAS_HOST_DATA_DIR", "") + t.Setenv("UPAAS_SESSION_SECRET", "test-secret") + setupViper("upaas") + + cfg, err := buildConfig(slog.Default(), &Params{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.HostDataDir != "./data" { + t.Errorf("expected HostDataDir ./data, got %q", cfg.HostDataDir) + } +}