diff --git a/.gitattributes b/.gitattributes index 69319bd..8b13789 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1 @@ -docker/nginx/certs/** filter=git-crypt diff=git-crypt -docker/nginx/data/keys.json filter=git-crypt diff=git-crypt -docker/nginx/data/database.sqlite filter=git-crypt diff=git-crypt diff --git a/.gitignore b/.gitignore index f0dac19..3996f56 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ containerd/ vultr/ -docker/nginx/data/logs/ +docker/nginx/data/ +docker/nginx/certs/ +docker/nginx/certbot/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..4c9a94c --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,121 @@ +# Architecture + +## Servers + +### Hantim server (hantim) + +The production application server. Runs Rocky Linux 9 on Vultr. + +- Hosts all app containers and the nginx reverse proxy via Docker Compose +- Provisioned by `setup.sh` in this repo +- This repo is cloned to `/opt/hantim` +- The `deploy` user receives SSH commands from CI to trigger deploys +- Certbot runs on the host (not in Docker) and manages Let's Encrypt certs + +### Gitea server (git.timothykim.net) + +A separate server running the Gitea instance. Provides: + +- Git hosting for all repos +- Docker container registry (`git.timothykim.net/hantim/`) +- Gitea Actions CI/CD (runner registered at site level) +- Organization-level secrets and variables shared across repos + +### Bitwarden (cloud) + +Bitwarden free tier is used as the secret store. Stores: + +- `hantim-git-crypt-key` -- git-crypt symmetric key (Secure Note, base64) +- `hantim-ci-registry-push` -- Gitea API token for registry access (Secure Note) +- `hantim-server-deploy` -- deploy user SSH key pair (SSH Key) +- `hantim-new-app-script` -- Gitea API token for creating repos (Secure Note) + +The Bitwarden CLI (`bw`) is used in `setup.sh` (on the server) and +`new-app.sh` (on dev machines) to fetch secrets at runtime. + +## Repos + +All repos live under the `hantim` organization on Gitea. + +### hantim-server + +This repo. Contains server provisioning, Docker Compose configs, nginx +configs, deploy scripts, and Gitea Actions workflows. + +``` +setup.sh # Server provisioning (idempotent) +scripts/deploy.sh # Receives deploy commands via SSH +scripts/new-app.sh # Scaffolds a new app (Gitea repo + local files) +docker/nginx/ # nginx reverse proxy + compose.yml # nginx:alpine container + nginx.conf # Main nginx config + conf.d/.conf # Per-app server blocks +docker//compose.yml # Per-app compose files +.gitea/workflows/deploy-.yml # Per-app deploy workflows +``` + +### static-site-template + +A Gitea template repo used by `new-app.sh` to scaffold new static sites. +Contains a Dockerfile, nginx config, placeholder HTML, and a `build.yml` +workflow that builds/pushes a Docker image and triggers a deploy. + +Uses Gitea template variables (`${REPO_NAME}`) so the image tag and deploy +command are automatically set to the repo name. + +### App repos (e.g., timothykim.net) + +Each app is its own repo created from `static-site-template`. Contains the +app source code and a `build.yml` workflow. + +## Network topology + +``` + Internet + | + [Hantim Server] + | + nginx (port 80/443) + | | + timothykim_net hcsuzuki_net ... + (shared Docker network) +``` + +- nginx is the only container with host port bindings (80, 443) +- All app containers are on the `shared` Docker network +- nginx proxies `https://www.` to the app's container by name +- The `shared` network is created by the nginx compose file and referenced + as `external: true` by app compose files + +## Deploy flow + +### App code change (push to app repo) + +``` +Developer pushes to app repo (e.g., timothykim.net) + -> build.yml workflow runs on Gitea runner + -> docker build + push to git.timothykim.net registry + -> SSH to deploy@hantim as deploy- + -> deploy.sh: git pull, docker compose pull, docker compose up -d + -> (if image not yet in registry, skips gracefully) +``` + +### Infrastructure change (push to hantim-server) + +``` +Developer pushes to hantim-server + -> deploy-.yml triggers (based on changed paths under docker//) + -> SSH to deploy@hantim as deploy- + -> deploy.sh: git pull, docker compose pull, docker compose up -d + -> (nginx only): test config with nginx -t before applying +``` + +## Conventions + +- **App name = domain** (e.g., `hcsuzuki.net`) +- **Container name** = domain with dots replaced by underscores (e.g., + `hcsuzuki_net`) +- **Cert name** = domain (stored at `/etc/letsencrypt/live//`) +- **Image name** = `git.timothykim.net/hantim/:latest` +- All HTTP traffic redirects to `https://www.` +- Bare domain HTTPS redirects to `https://www.` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0faf761 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,72 @@ +# CLAUDE.md + +## Project overview + +Server provisioning and app management for the hantim webserver. This repo +lives at `/opt/hantim` on the production server (Rocky Linux 9 on Vultr) and +manages Docker Compose configs, deploy scripts, nginx configs, and Gitea +Actions workflows. + +## Key files + +- `setup.sh` -- Server provisioning (idempotent, run as root) +- `scripts/deploy.sh` -- SSH-triggered deploy (validates command, pulls, restarts) +- `scripts/new-app.sh` -- Scaffolds a new static site (Gitea repo + compose + workflow + nginx conf) +- `docker/nginx/` -- Reverse proxy (nginx:alpine + certbot on host) +- `docker//compose.yml` -- Per-app compose files +- `.gitea/workflows/deploy-.yml` -- Per-app deploy workflows + +## Conventions + +- **App name = domain** (e.g., `hcsuzuki.net`) +- **Container name** = domain with dots replaced by underscores (e.g., `hcsuzuki_net`) +- **Image** = `git.timothykim.net/hantim/:latest` +- **Cert name** = domain (at `/etc/letsencrypt/live//`) +- All HTTP redirects to HTTPS; bare domain redirects to `www.` +- The `shared` Docker network is created by nginx compose and used by all apps + +## Secrets + +Stored in Bitwarden (free tier), fetched via `bw` CLI: +- `hantim-git-crypt-key` -- Secure Note (base64) +- `hantim-ci-registry-push` -- Secure Note (Gitea registry token) +- `hantim-server-deploy` -- SSH Key (deploy user key pair) +- `hantim-new-app-script` -- Secure Note (Gitea API token for creating repos) + +Gitea org-level (`hantim`) secrets/variables: +- `DEPLOY_HOST` -- Variable (server IP) +- `DEPLOY_SSH_KEY` -- Secret (deploy SSH private key) +- `CI_REGISTRY_TOKEN` -- Secret (registry token) + +## Working with this repo + +### Adding a new app + +Run `./scripts/new-app.sh ` from dev machine, then issue SSL cert on +server **before** pushing. See USECASES.md for full steps. + +### Editing scripts + +All shell scripts use `set -euo pipefail`. Keep all steps idempotent. +`setup.sh` must be safe to re-run on an existing server. + +### Deploy flow + +App repo push -> `build.yml` builds image, pushes to registry, SSHes to server +-> `deploy.sh` pulls repo, pulls image, restarts container. + +Infrastructure push (this repo) -> `deploy-.yml` triggers on path match +-> same `deploy.sh` flow. nginx deploys test config with `nginx -t` first. + +## Build and test + +No build step. No tests. Verify changes by: +1. Reading scripts and checking idempotency +2. Walking through each use case in USECASES.md +3. Cross-checking documentation (README.md, ARCHITECTURE.md, USECASES.md) against scripts + +## Commit style + +- Short, lowercase commit messages (e.g., "add hcsuzuki.net", "fix deploy command") +- Do NOT include "Co-Authored-By" lines +- Commit only when explicitly asked diff --git a/README.md b/README.md index 2dfd8e1..1da162f 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,28 @@ Server provisioning and app management for the hantim webserver. This repo lives at `/opt/hantim` on the server and contains Docker Compose configs, deploy -scripts, and Gitea Actions workflows for each app. +scripts, nginx configs, and Gitea Actions workflows for each app. ## Repo structure ``` -docker/ # One directory per app, each with a compose.yml - nginx/ # Nginx Proxy Manager (reverse proxy + TLS) - timothykim.net/ # timothykim.net static site +docker/ + nginx/ # Reverse proxy (nginx:alpine) + SSL config + nginx.conf # Main nginx config + conf.d/ # Per-app server blocks (e.g. timothykim.net.conf) + compose.yml + timothykim.net/ # timothykim.net static site + compose.yml scripts/ - deploy.sh # Deploy script (validates command, git pull, docker compose up) - new-app.sh # Scaffolding script to add a new app -setup.sh # One-time server provisioning script -.gitea/workflows/ # Per-app deploy workflows triggered by path changes + deploy.sh # Deploy script (validates command, git pull, docker compose up) + new-app.sh # Scaffolding script to add a new app +setup.sh # Server provisioning script (idempotent) +.gitea/workflows/ # Per-app deploy workflows triggered by path changes ``` -## Initial server setup +## Provisioning a new server -On a fresh Rocky Linux 9 (or compatible) install: +On a fresh Rocky Linux 9 install: ```bash dnf install -y git @@ -27,69 +31,87 @@ git clone https://git.timothykim.net/hantim/hantim-server.git /opt/hantim bash /opt/hantim/setup.sh ``` -`setup.sh` handles everything else: installs git-crypt and the Bitwarden CLI, -fetches the git-crypt key from your Bitwarden vault (stored as a base64-encoded -secure note named `hantim-git-crypt-key`), unlocks the repo, installs Docker, -creates the `deploy` user, and starts all services. +`setup.sh` is fully automated and idempotent. It: -To save the git-crypt key to Bitwarden (one-time, from your dev machine): +1. Installs system dependencies (git-crypt, jq, certbot, Node.js 20, Docker) +2. Installs and authenticates the Bitwarden CLI +3. Unlocks git-crypt using a key stored in Bitwarden +4. Fetches the Docker registry token and deploy SSH public key from Bitwarden +5. Logs into the Gitea Docker registry +6. Creates the `deploy` user with restricted SSH access and sudo +7. Issues SSL certificates via certbot for all configured domains +8. Starts all services (nginx first, then all apps) -```bash -base64 /path/to/git-crypt-key -# Save the output as a Bitwarden secure note named "hantim-git-crypt-key" -``` - -## How deploys work - -Each app has two components: - -1. **Gitea Actions workflow** (`.gitea/workflows/deploy-.yml`) -- triggers - on pushes to `main` when files under `docker//` change. SSHes into the - server as the `deploy` user to trigger the deploy. -2. **Deploy script** (`scripts/deploy.sh`) -- the `deploy` user's - `authorized_keys` is locked to this script via a `command=` directive. It - validates the command, pulls the latest repo changes, and runs - `docker compose pull && up -d` for that app. - -This means: - -- Pushing a change to `docker/nginx/compose.yml` only deploys nginx. -- Pushing a change to `scripts/` or `setup.sh` does not trigger any deploy. -- Each app deploys independently. +**Prerequisites**: DNS for all configured domains must point to the server +before running setup. ## Adding a new app -Run the scaffolding script: - ```bash -./scripts/new-app.sh +./scripts/new-app.sh ``` +Example: `./scripts/new-app.sh hcsuzuki.net` + This creates: -- `docker//compose.yml` -- a starter compose file on the `shared` - network -- `.gitea/workflows/deploy-.yml` -- the Gitea Actions workflow +- A Gitea repo (`hantim/`) from the `static-site-template` +- `docker//compose.yml` -- app compose file +- `.gitea/workflows/deploy-.yml` -- deploy workflow +- `docker/nginx/conf.d/.conf` -- nginx proxy config (HTTP->HTTPS, + bare domain->www) After running the script: -1. Edit `docker//compose.yml` to fit your app (image, ports, - volumes, environment variables, etc.). -2. Commit and push to `main`. -3. Configure the proxy host in Nginx Proxy Manager to route traffic to the new - service. +1. Issue the SSL cert on the server (**before** pushing): + ```bash + docker stop nginx + certbot certonly --standalone --non-interactive --agree-tos \ + --register-unsafely-without-email --cert-name \ + -d -d www. + docker start nginx + ``` +2. Commit and push this repo to deploy +3. Clone the new app repo, customize it, and push to build/deploy + +## Deploying changes + +**App code change** (push to an app repo like `timothykim.net`): +- The app's `build.yml` builds the Docker image, pushes to the registry, and + SSHes into the server to trigger a deploy + +**Compose or nginx config change** (push to this repo): +- Gitea Actions workflows trigger based on which `docker//` paths changed +- `deploy.sh` pulls the latest code and runs `docker compose up -d` +- For nginx deploys, the config is tested before applying to prevent downtime + +## Restoring from backup + +Run the same three commands as provisioning. `setup.sh` is idempotent: +- Existing packages are skipped +- Existing certs are skipped (or re-issued if the server is new) +- All services are started ## Secrets -Nginx Proxy Manager certs and Let's Encrypt account keys are encrypted with -git-crypt (see `.gitattributes`). You need the git-crypt key to unlock them. +The following are stored in Bitwarden and fetched automatically by `setup.sh`: -The following must be configured in the `hantim` Gitea org settings and are -shared across all repos: +| Bitwarden item | Type | Purpose | +|---|---|---| +| `hantim-git-crypt-key` | Secure Note | git-crypt symmetric key (base64) | +| `hantim-ci-registry-push` | Secure Note | Gitea token for Docker registry | +| `hantim-server-deploy` | SSH Key | Deploy user's SSH key | -Variables (Settings > Actions > Variables): -- `DEPLOY_HOST` -- the server's IP or hostname +The following are configured in the `hantim` Gitea org settings: -Secrets (Settings > Actions > Secrets): -- `DEPLOY_SSH_KEY` -- the SSH private key for the `deploy` user -- `CI_REGISTRY_TOKEN` -- Gitea personal access token for Docker registry login +| Name | Type | Purpose | +|---|---|---| +| `DEPLOY_HOST` | Variable | Server IP or hostname | +| `DEPLOY_SSH_KEY` | Secret | SSH private key for the deploy user | +| `CI_REGISTRY_TOKEN` | Secret | Gitea token for Docker registry login | + +The following is stored in Bitwarden and used by `new-app.sh`: + +| Bitwarden item | Type | Purpose | +|---|---|---| +| `hantim-new-app-script` | Secure Note | Gitea API token for creating repos | diff --git a/USECASES.md b/USECASES.md new file mode 100644 index 0000000..f00152b --- /dev/null +++ b/USECASES.md @@ -0,0 +1,141 @@ +# Use Cases + +## 1. Provision a new server + +Set up a fresh Rocky Linux 9 server from scratch. + +```bash +dnf install -y git +git clone https://git.timothykim.net/hantim/hantim-server.git /opt/hantim +bash /opt/hantim/setup.sh +``` + +**What happens:** +- Installs all dependencies (git-crypt, jq, certbot, Node.js 20, Docker, bw CLI) +- Fetches secrets from Bitwarden (git-crypt key, registry token, deploy SSH key) +- Unlocks git-crypt, logs into Docker registry +- Creates deploy user with restricted SSH + sudo +- Issues SSL certs for all domains in `docker/nginx/conf.d/` +- Starts nginx and all app containers + +**Prerequisites:** +- DNS for all configured domains must point to the server +- Bitwarden vault must contain: `hantim-git-crypt-key`, `hantim-ci-registry-push`, + `hantim-server-deploy` +- Docker images must exist in the Gitea registry (or apps will start on next push) + +## 2. Restore from backup + +Identical to provisioning a new server. Run the same three commands on a +fresh server. + +- `setup.sh` is idempotent -- safe to re-run +- SSL certs are re-issued automatically (Let's Encrypt) +- Docker images are pulled from the Gitea registry +- No data migration needed for stateless static sites + +## 3. Add a new static site + +From your dev machine: + +```bash +./scripts/new-app.sh hcsuzuki.net +``` + +Then on the server, issue the SSL cert **before** pushing: + +```bash +docker stop nginx +certbot certonly --standalone --non-interactive --agree-tos \ + --register-unsafely-without-email --cert-name hcsuzuki.net \ + -d hcsuzuki.net -d www.hcsuzuki.net +docker start nginx +``` + +Then commit and push this repo: + +```bash +git add docker/hcsuzuki.net/ .gitea/workflows/deploy-hcsuzuki.net.yml docker/nginx/conf.d/hcsuzuki.net.conf +git commit -m "add hcsuzuki.net" +git push +``` + +Finally, clone the new app repo, customize it, and push: + +```bash +git clone git@git.timothykim.net:hantim/hcsuzuki.net.git +cd hcsuzuki.net +# edit static/index.html, etc. +git add . && git commit -m "initial content" && git push +``` + +**What the script creates:** +- Gitea repo `hantim/hcsuzuki.net` (from `static-site-template`) +- `docker/hcsuzuki.net/compose.yml` (container name: `hcsuzuki_net`) +- `.gitea/workflows/deploy-hcsuzuki.net.yml` +- `docker/nginx/conf.d/hcsuzuki.net.conf` (HTTP->HTTPS, bare->www, proxy) + +## 4. Update app code + +Push a change to the app repo (e.g., `timothykim.net`): + +```bash +cd timothykim.net +# make changes +git add . && git commit -m "update content" && git push +``` + +**What happens:** +1. Gitea Actions runs `build.yml`: builds Docker image, pushes to registry +2. SSHes into server, runs `deploy-timothykim.net` +3. `deploy.sh` pulls latest hantim-server, pulls new image, restarts container + +No manual steps needed. + +## 5. Update Docker Compose config + +Edit a compose file in this repo and push: + +```bash +vim docker/timothykim.net/compose.yml +git add docker/timothykim.net/compose.yml +git commit -m "update timothykim.net config" +git push +``` + +**What happens:** +1. `deploy-timothykim.yml` workflow triggers (path match) +2. `deploy.sh` pulls latest code, pulls image, runs `docker compose up -d` + +## 6. Update nginx config + +Edit an nginx config in this repo and push: + +```bash +vim docker/nginx/conf.d/timothykim.net.conf +git add docker/nginx/conf.d/timothykim.net.conf +git commit -m "update nginx config" +git push +``` + +**What happens:** +1. `deploy-nginx.yml` workflow triggers (path match) +2. `deploy.sh` pulls latest code +3. Tests nginx config with `nginx -t` -- if it fails, deploy aborts and the + running nginx is untouched +4. Runs `docker compose up -d` and reloads nginx + +## 7. Re-run setup on existing server + +Safe to do at any time: + +```bash +cd /opt/hantim +git pull +bash setup.sh +``` + +- All steps are idempotent +- Existing certs are skipped +- Brief nginx downtime (seconds) while certbot checks run +- Services are restarted diff --git a/docker/nginx/certs/accounts/acme-v02.api.letsencrypt.org/directory/ba6ee99ad413477b2770a56352d98492/meta.json b/docker/nginx/certs/accounts/acme-v02.api.letsencrypt.org/directory/ba6ee99ad413477b2770a56352d98492/meta.json deleted file mode 100644 index 8fc35ff..0000000 Binary files a/docker/nginx/certs/accounts/acme-v02.api.letsencrypt.org/directory/ba6ee99ad413477b2770a56352d98492/meta.json and /dev/null differ diff --git a/docker/nginx/certs/accounts/acme-v02.api.letsencrypt.org/directory/ba6ee99ad413477b2770a56352d98492/private_key.json b/docker/nginx/certs/accounts/acme-v02.api.letsencrypt.org/directory/ba6ee99ad413477b2770a56352d98492/private_key.json deleted file mode 100644 index ba7ac57..0000000 Binary files a/docker/nginx/certs/accounts/acme-v02.api.letsencrypt.org/directory/ba6ee99ad413477b2770a56352d98492/private_key.json and /dev/null differ diff --git a/docker/nginx/certs/accounts/acme-v02.api.letsencrypt.org/directory/ba6ee99ad413477b2770a56352d98492/regr.json b/docker/nginx/certs/accounts/acme-v02.api.letsencrypt.org/directory/ba6ee99ad413477b2770a56352d98492/regr.json deleted file mode 100644 index 03cf353..0000000 Binary files a/docker/nginx/certs/accounts/acme-v02.api.letsencrypt.org/directory/ba6ee99ad413477b2770a56352d98492/regr.json and /dev/null differ diff --git a/docker/nginx/certs/archive/npm-1/cert1.pem b/docker/nginx/certs/archive/npm-1/cert1.pem deleted file mode 100644 index 87291d6..0000000 Binary files a/docker/nginx/certs/archive/npm-1/cert1.pem and /dev/null differ diff --git a/docker/nginx/certs/archive/npm-1/chain1.pem b/docker/nginx/certs/archive/npm-1/chain1.pem deleted file mode 100644 index 3045de3..0000000 Binary files a/docker/nginx/certs/archive/npm-1/chain1.pem and /dev/null differ diff --git a/docker/nginx/certs/archive/npm-1/fullchain1.pem b/docker/nginx/certs/archive/npm-1/fullchain1.pem deleted file mode 100644 index 1bcb336..0000000 Binary files a/docker/nginx/certs/archive/npm-1/fullchain1.pem and /dev/null differ diff --git a/docker/nginx/certs/archive/npm-1/privkey1.pem b/docker/nginx/certs/archive/npm-1/privkey1.pem deleted file mode 100644 index 843f29e..0000000 Binary files a/docker/nginx/certs/archive/npm-1/privkey1.pem and /dev/null differ diff --git a/docker/nginx/certs/archive/npm-2/cert1.pem b/docker/nginx/certs/archive/npm-2/cert1.pem deleted file mode 100644 index 6001c98..0000000 Binary files a/docker/nginx/certs/archive/npm-2/cert1.pem and /dev/null differ diff --git a/docker/nginx/certs/archive/npm-2/chain1.pem b/docker/nginx/certs/archive/npm-2/chain1.pem deleted file mode 100644 index cab35f4..0000000 Binary files a/docker/nginx/certs/archive/npm-2/chain1.pem and /dev/null differ diff --git a/docker/nginx/certs/archive/npm-2/fullchain1.pem b/docker/nginx/certs/archive/npm-2/fullchain1.pem deleted file mode 100644 index 2e7c9cf..0000000 Binary files a/docker/nginx/certs/archive/npm-2/fullchain1.pem and /dev/null differ diff --git a/docker/nginx/certs/archive/npm-2/privkey1.pem b/docker/nginx/certs/archive/npm-2/privkey1.pem deleted file mode 100644 index 9032d66..0000000 Binary files a/docker/nginx/certs/archive/npm-2/privkey1.pem and /dev/null differ diff --git a/docker/nginx/certs/archive/npm-3/cert1.pem b/docker/nginx/certs/archive/npm-3/cert1.pem deleted file mode 100644 index 8304cf4..0000000 Binary files a/docker/nginx/certs/archive/npm-3/cert1.pem and /dev/null differ diff --git a/docker/nginx/certs/archive/npm-3/chain1.pem b/docker/nginx/certs/archive/npm-3/chain1.pem deleted file mode 100644 index 3045de3..0000000 Binary files a/docker/nginx/certs/archive/npm-3/chain1.pem and /dev/null differ diff --git a/docker/nginx/certs/archive/npm-3/fullchain1.pem b/docker/nginx/certs/archive/npm-3/fullchain1.pem deleted file mode 100644 index 74c0ee3..0000000 Binary files a/docker/nginx/certs/archive/npm-3/fullchain1.pem and /dev/null differ diff --git a/docker/nginx/certs/archive/npm-3/privkey1.pem b/docker/nginx/certs/archive/npm-3/privkey1.pem deleted file mode 100644 index 6473fb8..0000000 Binary files a/docker/nginx/certs/archive/npm-3/privkey1.pem and /dev/null differ diff --git a/docker/nginx/certs/live/README b/docker/nginx/certs/live/README deleted file mode 100644 index 8bdfa31..0000000 Binary files a/docker/nginx/certs/live/README and /dev/null differ diff --git a/docker/nginx/certs/live/npm-1/README b/docker/nginx/certs/live/npm-1/README deleted file mode 100644 index 63f542b..0000000 Binary files a/docker/nginx/certs/live/npm-1/README and /dev/null differ diff --git a/docker/nginx/certs/live/npm-1/cert.pem b/docker/nginx/certs/live/npm-1/cert.pem deleted file mode 120000 index d7e65ed..0000000 --- a/docker/nginx/certs/live/npm-1/cert.pem +++ /dev/null @@ -1 +0,0 @@ -../../archive/npm-1/cert1.pem \ No newline at end of file diff --git a/docker/nginx/certs/live/npm-1/chain.pem b/docker/nginx/certs/live/npm-1/chain.pem deleted file mode 120000 index 8133afd..0000000 --- a/docker/nginx/certs/live/npm-1/chain.pem +++ /dev/null @@ -1 +0,0 @@ -../../archive/npm-1/chain1.pem \ No newline at end of file diff --git a/docker/nginx/certs/live/npm-1/fullchain.pem b/docker/nginx/certs/live/npm-1/fullchain.pem deleted file mode 120000 index 8f20152..0000000 --- a/docker/nginx/certs/live/npm-1/fullchain.pem +++ /dev/null @@ -1 +0,0 @@ -../../archive/npm-1/fullchain1.pem \ No newline at end of file diff --git a/docker/nginx/certs/live/npm-1/privkey.pem b/docker/nginx/certs/live/npm-1/privkey.pem deleted file mode 120000 index 916bbaf..0000000 --- a/docker/nginx/certs/live/npm-1/privkey.pem +++ /dev/null @@ -1 +0,0 @@ -../../archive/npm-1/privkey1.pem \ No newline at end of file diff --git a/docker/nginx/certs/live/npm-2/README b/docker/nginx/certs/live/npm-2/README deleted file mode 100644 index 63f542b..0000000 Binary files a/docker/nginx/certs/live/npm-2/README and /dev/null differ diff --git a/docker/nginx/certs/live/npm-2/cert.pem b/docker/nginx/certs/live/npm-2/cert.pem deleted file mode 120000 index 711bc75..0000000 --- a/docker/nginx/certs/live/npm-2/cert.pem +++ /dev/null @@ -1 +0,0 @@ -../../archive/npm-2/cert1.pem \ No newline at end of file diff --git a/docker/nginx/certs/live/npm-2/chain.pem b/docker/nginx/certs/live/npm-2/chain.pem deleted file mode 120000 index 689045b..0000000 --- a/docker/nginx/certs/live/npm-2/chain.pem +++ /dev/null @@ -1 +0,0 @@ -../../archive/npm-2/chain1.pem \ No newline at end of file diff --git a/docker/nginx/certs/live/npm-2/fullchain.pem b/docker/nginx/certs/live/npm-2/fullchain.pem deleted file mode 120000 index 742cc5e..0000000 --- a/docker/nginx/certs/live/npm-2/fullchain.pem +++ /dev/null @@ -1 +0,0 @@ -../../archive/npm-2/fullchain1.pem \ No newline at end of file diff --git a/docker/nginx/certs/live/npm-2/privkey.pem b/docker/nginx/certs/live/npm-2/privkey.pem deleted file mode 120000 index 0c8edcc..0000000 --- a/docker/nginx/certs/live/npm-2/privkey.pem +++ /dev/null @@ -1 +0,0 @@ -../../archive/npm-2/privkey1.pem \ No newline at end of file diff --git a/docker/nginx/certs/live/npm-3/README b/docker/nginx/certs/live/npm-3/README deleted file mode 100644 index 63f542b..0000000 Binary files a/docker/nginx/certs/live/npm-3/README and /dev/null differ diff --git a/docker/nginx/certs/live/npm-3/cert.pem b/docker/nginx/certs/live/npm-3/cert.pem deleted file mode 120000 index 70976c4..0000000 --- a/docker/nginx/certs/live/npm-3/cert.pem +++ /dev/null @@ -1 +0,0 @@ -../../archive/npm-3/cert1.pem \ No newline at end of file diff --git a/docker/nginx/certs/live/npm-3/chain.pem b/docker/nginx/certs/live/npm-3/chain.pem deleted file mode 120000 index 6f845d5..0000000 --- a/docker/nginx/certs/live/npm-3/chain.pem +++ /dev/null @@ -1 +0,0 @@ -../../archive/npm-3/chain1.pem \ No newline at end of file diff --git a/docker/nginx/certs/live/npm-3/fullchain.pem b/docker/nginx/certs/live/npm-3/fullchain.pem deleted file mode 120000 index 6a89319..0000000 --- a/docker/nginx/certs/live/npm-3/fullchain.pem +++ /dev/null @@ -1 +0,0 @@ -../../archive/npm-3/fullchain1.pem \ No newline at end of file diff --git a/docker/nginx/certs/live/npm-3/privkey.pem b/docker/nginx/certs/live/npm-3/privkey.pem deleted file mode 120000 index 0738548..0000000 --- a/docker/nginx/certs/live/npm-3/privkey.pem +++ /dev/null @@ -1 +0,0 @@ -../../archive/npm-3/privkey1.pem \ No newline at end of file diff --git a/docker/nginx/certs/renewal/npm-1.conf b/docker/nginx/certs/renewal/npm-1.conf deleted file mode 100644 index 28a6632..0000000 Binary files a/docker/nginx/certs/renewal/npm-1.conf and /dev/null differ diff --git a/docker/nginx/certs/renewal/npm-2.conf b/docker/nginx/certs/renewal/npm-2.conf deleted file mode 100644 index 1a51013..0000000 Binary files a/docker/nginx/certs/renewal/npm-2.conf and /dev/null differ diff --git a/docker/nginx/certs/renewal/npm-3.conf b/docker/nginx/certs/renewal/npm-3.conf deleted file mode 100644 index 81e44a7..0000000 Binary files a/docker/nginx/certs/renewal/npm-3.conf and /dev/null differ diff --git a/docker/nginx/compose.yml b/docker/nginx/compose.yml index 23b5f42..28ab511 100644 --- a/docker/nginx/compose.yml +++ b/docker/nginx/compose.yml @@ -1,17 +1,18 @@ services: - app: - image: 'jc21/nginx-proxy-manager:latest' + nginx: + image: nginx:alpine restart: unless-stopped - container_name: npm + container_name: nginx networks: - shared ports: - '80:80' - '443:443' - - '81:81' volumes: - - ./data:/data - - ./certs:/etc/letsencrypt + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ./conf.d:/etc/nginx/conf.d:ro + - /etc/letsencrypt:/etc/letsencrypt:ro + - ./certbot/www:/var/www/certbot:ro networks: shared: diff --git a/docker/nginx/conf.d/timothykim.net.conf b/docker/nginx/conf.d/timothykim.net.conf new file mode 100644 index 0000000..5c0d211 --- /dev/null +++ b/docker/nginx/conf.d/timothykim.net.conf @@ -0,0 +1,48 @@ +# Redirect HTTP to HTTPS, bare domain to www +server { + listen 80; + listen [::]:80; + server_name timothykim.net www.timothykim.net; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://www.timothykim.net$request_uri; + } +} + +# Redirect bare HTTPS domain to www +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name timothykim.net; + + ssl_certificate /etc/letsencrypt/live/timothykim.net/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/timothykim.net/privkey.pem; + + return 301 https://www.timothykim.net$request_uri; +} + +# Main site +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name www.timothykim.net; + + ssl_certificate /etc/letsencrypt/live/timothykim.net/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/timothykim.net/privkey.pem; + + add_header Strict-Transport-Security "max-age=63072000; preload" always; + + location / { + proxy_pass http://timothykim_net:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/docker/nginx/data/database.sqlite b/docker/nginx/data/database.sqlite deleted file mode 100644 index 4f81acb..0000000 Binary files a/docker/nginx/data/database.sqlite and /dev/null differ diff --git a/docker/nginx/data/keys.json b/docker/nginx/data/keys.json deleted file mode 100644 index a0d5c42..0000000 Binary files a/docker/nginx/data/keys.json and /dev/null differ diff --git a/docker/nginx/data/nginx/default_host/site.conf b/docker/nginx/data/nginx/default_host/site.conf deleted file mode 100644 index 6fdc1db..0000000 --- a/docker/nginx/data/nginx/default_host/site.conf +++ /dev/null @@ -1,20 +0,0 @@ -# ------------------------------------------------------------ -# Default Site -# ------------------------------------------------------------ - -server { - listen 80 default; -listen [::]:80 default; - - server_name default-host.localhost; - access_log /data/logs/default-host_access.log combined; - error_log /data/logs/default-host_error.log warn; - - - include conf.d/include/letsencrypt-acme-challenge.conf; - location / { - return 404; - } - -} - diff --git a/docker/nginx/data/nginx/proxy_host/1.conf b/docker/nginx/data/nginx/proxy_host/1.conf deleted file mode 100644 index 69499f3..0000000 --- a/docker/nginx/data/nginx/proxy_host/1.conf +++ /dev/null @@ -1,97 +0,0 @@ -# ------------------------------------------------------------ -# www.timothykim.net -# ------------------------------------------------------------ - - - -map $scheme $hsts_header { - https "max-age=63072000; preload"; -} - -server { - set $forward_scheme http; - set $server "static"; - set $port 80; - - listen 80; -listen [::]:80; - -listen 443 ssl; -listen [::]:443 ssl; - - - server_name www.timothykim.net; -http2 off; - - - # Let's Encrypt SSL - include conf.d/include/letsencrypt-acme-challenge.conf; - include conf.d/include/ssl-cache.conf; - include conf.d/include/ssl-ciphers.conf; - ssl_certificate /etc/letsencrypt/live/npm-3/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/npm-3/privkey.pem; - - - - -# Asset Caching - include conf.d/include/assets.conf; - - - # Block Exploits - include conf.d/include/block-exploits.conf; - - - - - - - - # Force SSL - - set $trust_forwarded_proto "F"; - - include conf.d/include/force-ssl.conf; - - - - -proxy_set_header Upgrade $http_upgrade; -proxy_set_header Connection $http_connection; -proxy_http_version 1.1; - - - access_log /data/logs/proxy-host-1_access.log proxy; - error_log /data/logs/proxy-host-1_error.log warn; - - - - - - - - location / { - - - - - - - - - - - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $http_connection; - proxy_http_version 1.1; - - - # Proxy! - include conf.d/include/proxy.conf; - } - - - # Custom - include /data/nginx/custom/server_proxy[.]conf; -} - diff --git a/docker/nginx/data/nginx/proxy_host/2.conf b/docker/nginx/data/nginx/proxy_host/2.conf deleted file mode 100644 index 9f72c31..0000000 --- a/docker/nginx/data/nginx/proxy_host/2.conf +++ /dev/null @@ -1,98 +0,0 @@ -# ------------------------------------------------------------ -# timothykim.net -# ------------------------------------------------------------ - - - -map $scheme $hsts_header { - https "max-age=63072000; preload"; -} - -server { - set $forward_scheme http; - set $server "static"; - set $port 80; - - listen 80; -listen [::]:80; - -listen 443 ssl; -listen [::]:443 ssl; - - - server_name timothykim.net; - - http2 on; - - - # Let's Encrypt SSL - include conf.d/include/letsencrypt-acme-challenge.conf; - include conf.d/include/ssl-cache.conf; - include conf.d/include/ssl-ciphers.conf; - ssl_certificate /etc/letsencrypt/live/npm-2/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/npm-2/privkey.pem; - - - - -# Asset Caching - include conf.d/include/assets.conf; - - - # Block Exploits - include conf.d/include/block-exploits.conf; - - - - - - - - # Force SSL - - set $trust_forwarded_proto "F"; - - include conf.d/include/force-ssl.conf; - - - - -proxy_set_header Upgrade $http_upgrade; -proxy_set_header Connection $http_connection; -proxy_http_version 1.1; - - - access_log /data/logs/proxy-host-2_access.log proxy; - error_log /data/logs/proxy-host-2_error.log warn; - -return 301 https://www.timothykim.net$request_uri; - - - - - - location / { - - - - - - - - - - - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $http_connection; - proxy_http_version 1.1; - - - # Proxy! - include conf.d/include/proxy.conf; - } - - - # Custom - include /data/nginx/custom/server_proxy[.]conf; -} - diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf new file mode 100644 index 0000000..364b7fb --- /dev/null +++ b/docker/nginx/nginx.conf @@ -0,0 +1,20 @@ +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + add_header X-Content-Type-Options nosniff; + add_header X-Frame-Options DENY; + + include /etc/nginx/conf.d/*.conf; +} diff --git a/docker/timothykim.net/compose.yml b/docker/timothykim.net/compose.yml index c846a9e..87671e1 100644 --- a/docker/timothykim.net/compose.yml +++ b/docker/timothykim.net/compose.yml @@ -2,7 +2,7 @@ services: static: image: git.timothykim.net/hantim/static:latest restart: unless-stopped - container_name: timothykim-static + container_name: timothykim_net networks: - shared diff --git a/scripts/deploy.sh b/scripts/deploy.sh index af1de28..397081e 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -12,5 +12,18 @@ APP="${CMD#deploy-}" cd /opt/hantim git pull cd "docker/$APP" -docker compose pull +if ! docker compose pull; then + echo "Image not yet available for $APP — skipping. It will deploy when the app repo is first pushed." + exit 0 +fi + +if [ "$APP" = "nginx" ]; then + # Test config before applying — a bad config (e.g. missing cert) would take down all sites + docker compose run --rm -T nginx nginx -t +fi + docker compose up -d + +if [ "$APP" = "nginx" ]; then + docker exec nginx nginx -s reload +fi diff --git a/scripts/new-app.sh b/scripts/new-app.sh index e265b4e..8abab14 100755 --- a/scripts/new-app.sh +++ b/scripts/new-app.sh @@ -1,13 +1,18 @@ #!/bin/bash set -euo pipefail +GITEA_URL="https://git.timothykim.net" +GITEA_ORG="hantim" +TEMPLATE_REPO="static-site-template" + if [ -z "${1:-}" ]; then - echo "Usage: ./scripts/new-app.sh " - echo "Example: ./scripts/new-app.sh my-blog" + echo "Usage: ./scripts/new-app.sh " + echo "Example: ./scripts/new-app.sh hcsuzuki.net" exit 1 fi APP="$1" +CONTAINER_NAME="${APP//./_}" if ! [[ "$APP" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]]; then echo "Error: app name must be alphanumeric (hyphens, dots, underscores allowed)." @@ -21,6 +26,60 @@ if [ -d "$REPO_ROOT/docker/$APP" ]; then exit 1 fi +# --- Check dependencies --- + +if ! command -v bw &>/dev/null; then + echo "Error: Bitwarden CLI (bw) is not installed." + echo " npm install -g @bitwarden/cli" + exit 1 +fi + +if ! command -v jq &>/dev/null; then + echo "Error: jq is not installed." + exit 1 +fi + +# --- Get Gitea API token from Bitwarden --- + +if bw status 2>/dev/null | grep -q '"status":"unauthenticated"'; then + echo "Log in to Bitwarden:" + bw login +fi + +if bw status 2>/dev/null | grep -q '"status":"locked"'; then + BW_SESSION=$(bw unlock --raw) + export BW_SESSION +fi + +bw sync --session "${BW_SESSION:-}" +GITEA_TOKEN=$(bw get notes hantim-new-app-script --session "${BW_SESSION:-}") + +# --- Create Gitea repo from template --- + +echo "Creating Gitea repo $GITEA_ORG/$APP from template $TEMPLATE_REPO..." +HTTP_CODE=$(curl -s -o /tmp/new-app-response.json -w "%{http_code}" \ + -X POST "$GITEA_URL/api/v1/repos/$GITEA_ORG/$TEMPLATE_REPO/generate" \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"owner\": \"$GITEA_ORG\", + \"name\": \"$APP\", + \"git_content\": true, + \"labels\": false, + \"webhooks\": false + }") + +if [ "$HTTP_CODE" != "201" ]; then + echo "Error: Failed to create repo (HTTP $HTTP_CODE)" + cat /tmp/new-app-response.json + rm -f /tmp/new-app-response.json + exit 1 +fi +rm -f /tmp/new-app-response.json +echo " Created: $GITEA_URL/$GITEA_ORG/$APP" + +# --- Create local files in hantim-server --- + echo "Creating docker/$APP/compose.yml..." mkdir -p "$REPO_ROOT/docker/$APP" cat > "$REPO_ROOT/docker/$APP/compose.yml" < "$REPO_ROOT/docker/nginx/conf.d/$APP.conf" < Upgrading system packages..." dnf upgrade -y echo "==> Installing dependencies..." -dnf install -y git-crypt jq +dnf install -y git-crypt jq epel-release +dnf install -y certbot dnf module reset -y nodejs dnf module install -y nodejs:20/common @@ -104,12 +105,57 @@ chmod 440 /etc/sudoers.d/deploy echo "==> Making scripts executable..." chmod +x "$REPO_DIR/scripts/"*.sh +echo "==> Setting up certbot..." +mkdir -p "$REPO_DIR/docker/nginx/certbot/www" +mkdir -p /etc/letsencrypt/renewal-hooks/deploy +cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'HOOK' +#!/bin/bash +docker exec nginx nginx -s reload +HOOK +chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh +if systemctl list-unit-files certbot-renew.timer 2>/dev/null | grep -q certbot-renew; then + systemctl enable --now certbot-renew.timer +else + echo "0 3 * * * root certbot renew --quiet" > /etc/cron.d/certbot-renew +fi + +echo "==> Issuing SSL certificates..." +# Stop nginx if running so certbot can bind to port 80 +docker stop nginx 2>/dev/null || true +for conf in "$REPO_DIR"/docker/nginx/conf.d/*.conf; do + # Derive cert name from config filename (e.g. timothykim.net.conf -> timothykim.net) + cert_name=$(basename "$conf" .conf) + domains=$(grep -oP 'server_name\s+\K[^;]+' "$conf" | tr ' ' '\n' | sort -u) + if [ -z "$domains" ]; then + continue + fi + if [ -d "/etc/letsencrypt/live/$cert_name" ]; then + echo " Cert for $cert_name already exists, skipping." + continue + fi + domain_args="" + for d in $domains; do + domain_args="$domain_args -d $d" + done + echo " Issuing cert for: $cert_name ($domains)" + certbot certonly --standalone --non-interactive --agree-tos --register-unsafely-without-email --cert-name "$cert_name" $domain_args +done + echo "==> Starting services..." +# Start nginx first (creates the shared network) cd "$REPO_DIR/docker/nginx" docker compose up -d -cd "$REPO_DIR/docker/timothykim.net" -docker compose up -d +# Start all other apps +for app in "$REPO_DIR"/docker/*/; do + if [ "$(basename "$app")" = "nginx" ]; then + continue + fi + app_name=$(basename "$app") + echo " Starting $app_name..." + cd "$app" + if ! docker compose up -d; then + echo " WARNING: Failed to start $app_name (image may not exist yet). It will start on first deploy." + fi +done -echo "==> Done. Don't forget to:" -echo " 1. Configure Nginx Proxy Manager at http://:81" -echo " 2. Restore NPM data/certs backup if migrating" +echo "==> Done."