Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,8 @@ Deployment helpers live in this repository, entirely under `deploy/`, run only o
- `.github/actions/encrypt-env/` is a local composite action for rendering `vaults/` manifests from GitHub Secrets/Variables, encrypting them for age recipients, and publishing `.sops.env` as a GitHub Release asset — vault manifests hold only env/secrets, not app selection
- `.github/workflows/deploy-shared.yml` is a reusable workflow consumer repos call to run `deploy/deploy.py` from GitHub Actions over an optional Tailscale connection, without holding any deploy secrets in this repository

The `releases/{timestamp}`/`current` symlink pattern (Capistrano-style) exists for atomicity, not for rollback: a deploy either fully lands and only then switches the symlink as its last step, or fails partway and leaves `current` untouched — never a partially-applied app. There is deliberately no automated rollback, and manual rollback (point `current` at an old release directory by hand) is not a supported/maintained path — it wouldn't restore that release's rendered config templates (`apps-data/{app}/config/` isn't versioned per release) or a floating-tag image's historical version either, and in practice fixing forward through the normal deploy path is simpler and safer than reasoning about what a partial rollback actually restores.

App bundles listed in `app_refs` are release assets referenced as short refs like `<owner>/<repo>@latest` or `<owner>/<repo>@v1.2.3`, resolving to a default asset name of `flightdeck-apps.zip` unless the ref specifies an explicit `:asset-name` suffix. `@latest` is resolved through GitHub's latest release API. Every bundle must contain an `apps/` directory; both per-app directories and shared top-level files (`common.yml`, `networks.yml`, etc.) merge the same way — copy if new, fail loud on any name conflict across bundles.

Each app in a target's `apps` mapping lists its own `env_refs` — release refs the same shape as app bundles, with no default asset name (every entry must specify an explicit `:asset-name` suffix, since there's no single obvious default under a per-app model). `deploy/deploy.py` downloads them still encrypted and checks for key collisions from the ciphertext (SOPS's dotenv output only encrypts values, so key names are readable without decryption) — scoped to that one app's own sources, not across apps, since each app ends up with its own separate `.env`. Decryption itself happens on the runner too, using the target's private age key (`credentials.secrets.sops_age_key`, a GitHub Secret) - the target host never holds this key and never runs `sops`. `apps` moved off the vault schema onto the target, and vaults moved from one-per-target to one-per-app, so that a vault can declare its output env var names directly (`HTTP_PORT`, not `TRAEFIK_HTTP_PORT`) without an implicit prefix-strip happening anywhere between the vault and the app's `.env`.
Expand Down
93 changes: 26 additions & 67 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@ The deploy is push-based and runs entirely on the GitHub Actions runner:
5. Render that app's config templates (`config/*.template.*`) using the decrypted values — the same substitution `envsubst` does, run here instead of on the host.
6. Push the finished release (real `.env`, already-rendered config) to each host over SSH, switch the `current` symlink, and run `docker compose pull && docker compose up -d` per app.

Nothing decrypts or renders on the target host. The only thing it ever receives is a finished, ready-to-run Docker Compose project per app.

What gets deployed — which app bundles, which apps actually run, and which encrypted env sources feed each one — is configured declaratively per target; see "Vaults And Targets" below for the manifest format.

## 📁 Project Structure
Expand Down Expand Up @@ -166,70 +164,25 @@ Add the app to whichever target's `apps` mapping should run it, and give it a va

## 🔍 Troubleshooting

Since there's no manual administration path, all of the following happen by SSHing into the target host directly, for debugging only:

### Container Won't Start

```bash
# Check logs
cd apps/app-name && docker compose logs -f

# Validate docker-compose configuration
cd apps/app-name && docker compose config

# Check network connectivity
docker network ls
docker network inspect traefik
```

### SSL Certificate Issues
There's no manual administration path, so debugging means SSHing into the target host and using Docker Compose directly from the app's own folder — no wrapper needed, `apps/{app}/` is already a complete, ready-to-run Compose project:

```bash
# Check Traefik logs
cd apps/traefik && docker compose logs -f

# Verify ACME certificate file
ls -la apps-data/traefik/acme.json

# Ensure correct permissions
chmod 600 apps-data/traefik/acme.json
cd apps/app-name
docker compose logs -f # tail logs
docker compose config # validate/inspect the resolved config
docker ps | grep app-name # confirm it's running
```

### Application Not Accessible

1. Verify app is running: `docker ps | grep app-name`
2. Check app logs: `cd apps/app-name && docker compose logs -f`
3. Check Traefik logs: `cd apps/traefik && docker compose logs -f`
4. Verify DNS resolves: `nslookup app-name.domain.com`
5. Test internal connectivity: `docker exec -it traefik wget -q --spider http://app-name`

## 🔐 Security Best Practices
A few things that don't fit that one-liner:

1. **Change Default Credentials** - Update vault-sourced secrets and re-deploy
2. **Use Strong Passwords** - Generate with: `openssl rand -base64 32`
3. **Keep Images Updated** - every deploy runs `docker compose pull` before `up`, so all apps get their latest image on every deploy
4. **Restrict Network Access** - Use firewall rules to limit access to Traefik ports (80, 443)
5. **Enable HTTPS** - Always use HTTPS, never expose HTTP to internet
6. **Backup Data** - Regularly back up `apps-data/` (backup automation is a separate, not-yet-decided piece of tooling)
7. **Monitor Logs** - Review logs regularly for errors and unauthorized access attempts
8. **Update Dependencies** - Check for updates: `docker pull app:latest`
- **Networking**: `docker network ls` / `docker network inspect traefik` to check connectivity; `docker exec -it traefik wget -q --spider http://app-name` to test an app's reachability from inside the `traefik` network.
- **SSL**: `apps-data/traefik/acme.json` must exist and be `chmod 600`, or Traefik won't issue certificates.
- **DNS**: `nslookup app-name.domain.com` if the app resolves but isn't reachable.

## 📚 Additional Resources

- [AGENTS.md](AGENTS.md) - Technical documentation for AI agents and developers
- [RETIRED.md](RETIRED.md) - Apps removed from the active stack, and why
- [Docker Documentation](https://docs.docker.com/)
- [Docker Compose Documentation](https://docs.docker.com/compose/)
- [Traefik Documentation](https://doc.traefik.io/)

## 🤝 Contributing

Contributions are welcome! To add a new application:

1. Follow the "Adding a New Application" section
2. Verify by deploying it to a real target
3. Document any special requirements
4. Submit a pull request with the new app configuration

## 🔄 Similar Services

Expand Down Expand Up @@ -286,8 +239,8 @@ apps:
env_refs:
- owner/config@latest:mainframe-rybbit.sops.env
hosts:
- deploy@100.64.0.1
- deploy@100.64.0.2
- deploy@app1.example.com
- deploy@app2.example.com
path: ~/flightdeck # optional, default shown
credentials:
variables:
Expand Down Expand Up @@ -341,6 +294,8 @@ Secrets take precedence over Variables when both contain the same source key. Ev

Builds a zip archive from caller-selected paths, rejects runtime state and env files, and uploads it to an existing GitHub Release. `paths` and `bundle-name` are required — this is a generic, reusable primitive (`build-apps-bundle` below is the only current caller).

<!-- x-release-please-start-version -->

```yaml
steps:
- uses: actions/checkout@v7
Expand All @@ -354,6 +309,8 @@ steps:
token: ${{ secrets.GITHUB_TOKEN }}
```

<!-- x-release-please-end -->

Requires `contents: write` permission on the calling job.

---
Expand All @@ -362,6 +319,8 @@ Requires `contents: write` permission on the calling job.

A thin defaults wrapper around `build-bundle`: `paths` defaults to `apps`, `bundle-name` defaults to `flightdeck-apps.zip`. The same action publishes flightdeck's own `apps/` catalog and any consumer repository's own app bundle.

<!-- x-release-please-start-version -->

```yaml
steps:
- uses: actions/checkout@v7
Expand All @@ -373,6 +332,8 @@ steps:
token: ${{ secrets.GITHUB_TOKEN }}
```

<!-- x-release-please-end -->

Requires `contents: write` permission on the calling job. `flightdeck-apps.zip` is the default asset name an `app_refs` entry resolves to when it doesn't specify an explicit `:asset-name` suffix; override `bundle-name` and use that suffix when publishing under a different filename.

---
Expand All @@ -385,12 +346,14 @@ The interface is plain deploy vocabulary — callers never see `deploy.py`'s int

Tailscale is optional, not a dependency of this workflow: set `tailscale-oauth-client-id` (and the matching `tailscale-oauth-secret`) to have the runner join a tailnet as an ephemeral node before deploying. Leave both unset to skip that step entirely — e.g. when the job already runs on a self-hosted runner with network access to the hosts, or reaches them some other way.

<!-- x-release-please-start-version -->

```yaml
jobs:
deploy:
uses: rubykatzen/flightdeck/.github/workflows/deploy-shared.yml@v1.2.3
with:
hosts: '["deploy@100.64.0.1", "deploy@100.64.0.2"]' # required JSON array
hosts: '["deploy@app1.example.com", "deploy@app2.example.com"]' # required JSON array
app-refs: '["rubykatzen/flightdeck@latest"]' # required non-empty JSON array
apps: '{"traefik": {"env_refs": ["${{ github.repository }}@latest:mainframe-traefik.sops.env"]}}' # required non-empty JSON object
# path: ~/flightdeck # optional, default shown
Expand All @@ -403,14 +366,10 @@ jobs:
tailscale-oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} # optional, required only if tailscale-oauth-client-id is set
```

The `@v1.2.3` pin on the `uses:` line only controls which ref runs `deploy/deploy.py` itself. `app-refs` entries are separate and don't have to match the workflow pin.

## 📝 License
<!-- x-release-please-end -->

This project is provided as-is for self-hosted deployment.
The `@v1.2.3` pin on the `uses:` line only controls which ref runs `deploy/deploy.py` itself. `app-refs` entries are separate and don't have to match the workflow pin. <!-- x-release-please-version -->

---
## 📝 License

**Last Updated**: 2026
**Supported Docker Compose**: >= 2.0
**Status**: Active Development
[MIT](LICENSE)
9 changes: 8 additions & 1 deletion release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
"pull-request-header": "This pull request prepares the next release.",
"pull-request-footer": "[Release Please documentation](https://github.com/googleapis/release-please#release-please)",
"packages": {
".": {}
".": {
"extra-files": [
{
"type": "generic",
"path": "README.md"
}
]
}
}
}