replace NPM with vanilla nginx + certbot
Deploy nginx / deploy (push) Failing after 3s
Deploy timothykim.net / deploy (push) Successful in 3s

- nginx:alpine replaces nginx-proxy-manager
- certbot on host with standalone issuance and auto-renewal
- per-site nginx configs in conf.d/ (HTTP->HTTPS, bare->www)
- container names use underscores (timothykim_net)
- setup.sh: automated cert issuance, dynamic app startup
- deploy.sh: nginx -t guard, graceful image-not-found
- new-app.sh: Gitea API repo creation, nginx conf generation
- add ARCHITECTURE.md, USECASES.md, CLAUDE.md
- remove old NPM data/certs from tracking
This commit is contained in:
2026-03-16 00:55:13 -04:00
parent 31c6235726
commit 5c38b0631a
52 changed files with 681 additions and 307 deletions
-3
View File
@@ -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
+3 -1
View File
@@ -1,3 +1,5 @@
containerd/
vultr/
docker/nginx/data/logs/
docker/nginx/data/
docker/nginx/certs/
docker/nginx/certbot/
+121
View File
@@ -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/<app>`)
- 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/<domain>.conf # Per-app server blocks
docker/<domain>/compose.yml # Per-app compose files
.gitea/workflows/deploy-<app>.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.<domain>` 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-<domain>
-> 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-<app>.yml triggers (based on changed paths under docker/<app>/)
-> SSH to deploy@hantim as deploy-<app>
-> 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/<domain>/`)
- **Image name** = `git.timothykim.net/hantim/<domain>:latest`
- All HTTP traffic redirects to `https://www.<domain>`
- Bare domain HTTPS redirects to `https://www.<domain>`
+72
View File
@@ -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/<domain>/compose.yml` -- Per-app compose files
- `.gitea/workflows/deploy-<app>.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/<domain>:latest`
- **Cert name** = domain (at `/etc/letsencrypt/live/<domain>/`)
- All HTTP redirects to HTTPS; bare domain redirects to `www.<domain>`
- 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 <domain>` 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-<app>.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
+79 -57
View File
@@ -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-<app>.yml`) -- triggers
on pushes to `main` when files under `docker/<app>/` 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 <app-name>
./scripts/new-app.sh <domain>
```
Example: `./scripts/new-app.sh hcsuzuki.net`
This creates:
- `docker/<app-name>/compose.yml` -- a starter compose file on the `shared`
network
- `.gitea/workflows/deploy-<app-name>.yml` -- the Gitea Actions workflow
- A Gitea repo (`hantim/<domain>`) from the `static-site-template`
- `docker/<domain>/compose.yml` -- app compose file
- `.gitea/workflows/deploy-<domain>.yml` -- deploy workflow
- `docker/nginx/conf.d/<domain>.conf` -- nginx proxy config (HTTP->HTTPS,
bare domain->www)
After running the script:
1. Edit `docker/<app-name>/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 <domain> \
-d <domain> -d www.<domain>
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/<app>/` 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 |
+141
View File
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
../../archive/npm-1/cert1.pem
-1
View File
@@ -1 +0,0 @@
../../archive/npm-1/chain1.pem
@@ -1 +0,0 @@
../../archive/npm-1/fullchain1.pem
@@ -1 +0,0 @@
../../archive/npm-1/privkey1.pem
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
../../archive/npm-2/cert1.pem
-1
View File
@@ -1 +0,0 @@
../../archive/npm-2/chain1.pem
@@ -1 +0,0 @@
../../archive/npm-2/fullchain1.pem
@@ -1 +0,0 @@
../../archive/npm-2/privkey1.pem
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
../../archive/npm-3/cert1.pem
-1
View File
@@ -1 +0,0 @@
../../archive/npm-3/chain1.pem
@@ -1 +0,0 @@
../../archive/npm-3/fullchain1.pem
@@ -1 +0,0 @@
../../archive/npm-3/privkey1.pem
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7 -6
View File
@@ -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:
+48
View File
@@ -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;
}
}
Binary file not shown.
Binary file not shown.
@@ -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;
}
}
-97
View File
@@ -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;
}
-98
View File
@@ -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;
}
+20
View File
@@ -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;
}
+1 -1
View File
@@ -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
+14 -1
View File
@@ -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
+123 -5
View File
@@ -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 <app-name>"
echo "Example: ./scripts/new-app.sh my-blog"
echo "Usage: ./scripts/new-app.sh <domain>"
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" <<EOF
@@ -28,6 +87,7 @@ services:
app:
image: git.timothykim.net/hantim/$APP:latest
restart: unless-stopped
container_name: $CONTAINER_NAME
networks:
- shared
@@ -60,12 +120,70 @@ jobs:
OUTER
sed -i "s/APP_PLACEHOLDER/$APP/g" "$REPO_ROOT/.gitea/workflows/deploy-$APP.yml"
echo "Creating docker/nginx/conf.d/$APP.conf..."
cat > "$REPO_ROOT/docker/nginx/conf.d/$APP.conf" <<NGINX
# Redirect HTTP to HTTPS, bare domain to www
server {
listen 80;
listen [::]:80;
server_name $APP www.$APP;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://www.$APP\$request_uri;
}
}
# Redirect bare HTTPS domain to www
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name $APP;
ssl_certificate /etc/letsencrypt/live/$APP/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$APP/privkey.pem;
return 301 https://www.$APP\$request_uri;
}
# Main site
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name www.$APP;
ssl_certificate /etc/letsencrypt/live/$APP/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$APP/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000; preload" always;
location / {
proxy_pass http://$CONTAINER_NAME: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;
}
}
NGINX
echo ""
echo "Done! Files created:"
echo " - Gitea repo: $GITEA_URL/$GITEA_ORG/$APP"
echo " - docker/$APP/compose.yml"
echo " - .gitea/workflows/deploy-$APP.yml"
echo " - docker/nginx/conf.d/$APP.conf"
echo ""
echo "Next steps:"
echo " 1. Edit docker/$APP/compose.yml to fit your app"
echo " 2. Commit and push to deploy"
echo " 3. Configure the proxy host in Nginx Proxy Manager"
echo " 1. Issue SSL cert on the server (BEFORE pushing nginx config):"
echo " docker stop nginx"
echo " certbot certonly --standalone --non-interactive --agree-tos --register-unsafely-without-email --cert-name $APP -d $APP -d www.$APP"
echo " docker start nginx"
echo " 2. Commit and push hantim-server to deploy"
echo " 3. Clone $GITEA_URL/$GITEA_ORG/$APP and customize it"
echo " 4. Edit docker/$APP/compose.yml if needed"
+52 -6
View File
@@ -32,7 +32,8 @@ echo "==> 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://<server-ip>:81"
echo " 2. Restore NPM data/certs backup if migrating"
echo "==> Done."