mirror of
https://github.com/cole-maxwell1/todo-hetzner.git
synced 2026-10-27 19:02:49 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bbb5b08f9 | ||
|
|
94d18ff8e2 | ||
|
|
b8f655ac58 | ||
|
|
eaf53f8256 |
@@ -3,19 +3,27 @@
|
|||||||
.idea/
|
.idea/
|
||||||
.zed/
|
.zed/
|
||||||
|
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Terraform state files
|
||||||
*.tfstate
|
*.tfstate
|
||||||
*.tfstate.*
|
*.tfstate.*
|
||||||
*.tfplan
|
*.tfplan
|
||||||
.terraform/
|
.terraform/
|
||||||
infra/terraform.tfvars
|
infra/terraform.tfvars
|
||||||
infra/**/*.tfvars
|
infra/**/*.tfvars
|
||||||
|
|
||||||
|
# Ansible specific
|
||||||
ansible/inventory/hosts.yml
|
ansible/inventory/hosts.yml
|
||||||
ansible/inventory/group_vars/all/main.yml
|
ansible/inventory/group_vars/all/main.yml
|
||||||
ansible/inventory/group_vars/all/vault.yml
|
ansible/inventory/group_vars/all/vault.yml
|
||||||
ansible/inventory/group_vars/prod/vault.yml
|
ansible/inventory/group_vars/prod/vault.yml
|
||||||
ansible/inventory/group_vars/staging/vault.yml
|
ansible/inventory/group_vars/staging/vault.yml
|
||||||
|
ansible/.vault_pass.txt
|
||||||
|
|
||||||
|
# App build output
|
||||||
app/bin/
|
app/bin/
|
||||||
app/tailwindcss
|
app/tailwindcss
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,43 @@ Staging contains an unchanged copy of production todo data. Treat staging hosts,
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
client([HTTP clients])
|
||||||
|
|
||||||
|
subgraph cloud[Hetzner Cloud]
|
||||||
|
lb[Hetzner Load Balancer]
|
||||||
|
|
||||||
|
subgraph network[Private Network 10.0.0.0/16]
|
||||||
|
subgraph prod[Production VM - Rocky Linux 10]
|
||||||
|
prod_app[Go App<br/>Rootless Podman]
|
||||||
|
prod_db[(PostgreSQL<br/>127.0.0.1:5432)]
|
||||||
|
prod_app -->|Local connection| prod_db
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph staging[Staging VM - Rocky Linux 10]
|
||||||
|
staging_app[Go App<br/>Rootless Podman]
|
||||||
|
staging_db[(PostgreSQL<br/>127.0.0.1:5432)]
|
||||||
|
staging_app -->|Local connection| staging_db
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
storage[(Hetzner Object Storage<br/>S3-compatible bucket)]
|
||||||
|
end
|
||||||
|
|
||||||
|
client -->|Port 80| lb
|
||||||
|
lb -->|10.0.1.10:8080| prod_app
|
||||||
|
prod_db -->|WAL archive and hourly base backup| storage
|
||||||
|
storage -->|Restore LATEST at 06:00 and 12:00| staging_db
|
||||||
|
|
||||||
|
classDef production fill:#fee2e2,stroke:#b91c1c,color:#7f1d1d,stroke-width:2px;
|
||||||
|
classDef stagingEnv fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a,stroke-width:2px;
|
||||||
|
class prod_app,prod_db production;
|
||||||
|
class staging_app,staging_db stagingEnv;
|
||||||
|
style prod fill:#fff1f2,stroke:#b91c1c,stroke-width:2px
|
||||||
|
style staging fill:#eff6ff,stroke:#1d4ed8,stroke-width:2px
|
||||||
|
```
|
||||||
|
|
||||||
- The load balancer accepts HTTP on port 80 and reaches production on `10.0.1.10:8080` over the private network.
|
- The load balancer accepts HTTP on port 80 and reaches production on `10.0.1.10:8080` over the private network.
|
||||||
- The Go application runs as the rootless `todo` user through Quadlet-generated `app-todo.service` with host networking.
|
- The Go application runs as the rootless `todo` user through Quadlet-generated `app-todo.service` with host networking.
|
||||||
- PostgreSQL listens only on `127.0.0.1:5432`; the cloud firewall does not expose it.
|
- PostgreSQL listens only on `127.0.0.1:5432`; the cloud firewall does not expose it.
|
||||||
@@ -326,10 +363,22 @@ cd ansible
|
|||||||
ansible-playbook -i inventory/hosts.yml playbooks/setup-node.yml --ask-vault-pass
|
ansible-playbook -i inventory/hosts.yml playbooks/setup-node.yml --ask-vault-pass
|
||||||
```
|
```
|
||||||
|
|
||||||
The Make deployment target loads `ansible/ansible.cfg` by changing into that directory and regenerates inventory before deployment. Configure `ANSIBLE_VAULT_PASSWORD_FILE` so both playbooks can unlock the Vault files non-interactively:
|
For non-interactive deployments, Ansible needs a file containing the password used to encrypt all three Vault files. The project configuration defaults to `ansible/.vault_pass.txt`; create it from the example, replace the placeholder with the Vault password, and restrict its permissions:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp ansible/.vault_pass.txt.example ansible/.vault_pass.txt
|
||||||
|
chmod 600 ansible/.vault_pass.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
`ansible/.vault_pass.txt` is ignored by Git and must never be committed. All three Vault files must use the password stored in this file. To keep the password outside the repository instead, set `ANSIBLE_VAULT_PASSWORD_FILE`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export ANSIBLE_VAULT_PASSWORD_FILE="$HOME/.config/ansible/todo-vault-password"
|
export ANSIBLE_VAULT_PASSWORD_FILE="$HOME/.config/ansible/todo-vault-password"
|
||||||
|
```
|
||||||
|
|
||||||
|
The Make deployment target loads `ansible/ansible.cfg`, regenerates inventory, and unlocks the Vault files non-interactively:
|
||||||
|
|
||||||
|
```sh
|
||||||
make deploy-ansible
|
make deploy-ansible
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
your-vault-password
|
||||||
@@ -4,6 +4,7 @@ roles_path = roles
|
|||||||
interpreter_python = auto_silent
|
interpreter_python = auto_silent
|
||||||
host_key_checking = True
|
host_key_checking = True
|
||||||
retry_files_enabled = False
|
retry_files_enabled = False
|
||||||
|
vault_password_file = .vault_pass.txt
|
||||||
|
|
||||||
[ssh_connection]
|
[ssh_connection]
|
||||||
ssh_args = -o StrictHostKeyChecking=accept-new
|
ssh_args = -o StrictHostKeyChecking=accept-new
|
||||||
|
|||||||
@@ -8,6 +8,14 @@
|
|||||||
ansible.builtin.set_fact:
|
ansible.builtin.set_fact:
|
||||||
app_user_uid: "{{ ansible_facts.getent_passwd[app_user][1] }}"
|
app_user_uid: "{{ ansible_facts.getent_passwd[app_user][1] }}"
|
||||||
|
|
||||||
|
- name: Create application user Ansible temporary directory
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "/home/{{ app_user }}/.ansible/tmp"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ app_user }}"
|
||||||
|
group: "{{ app_user }}"
|
||||||
|
mode: "0700"
|
||||||
|
|
||||||
- name: Create Quadlet directory
|
- name: Create Quadlet directory
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "/home/{{ app_user }}/.config/containers/systemd"
|
path: "/home/{{ app_user }}/.config/containers/systemd"
|
||||||
@@ -60,12 +68,3 @@
|
|||||||
environment:
|
environment:
|
||||||
XDG_RUNTIME_DIR: "/run/user/{{ app_user_uid }}"
|
XDG_RUNTIME_DIR: "/run/user/{{ app_user_uid }}"
|
||||||
changed_when: false
|
changed_when: false
|
||||||
|
|
||||||
- name: Enable application service without starting it
|
|
||||||
ansible.builtin.command:
|
|
||||||
argv: [systemctl, --user, enable, app-todo.service]
|
|
||||||
become_user: "{{ app_user }}"
|
|
||||||
environment:
|
|
||||||
XDG_RUNTIME_DIR: "/run/user/{{ app_user_uid }}"
|
|
||||||
register: app_enable
|
|
||||||
changed_when: "'Created symlink' in app_enable.stderr"
|
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ func (h *Handler) serverError(w http.ResponseWriter, r *http.Request, err error)
|
|||||||
func (h *Handler) security(next http.Handler) http.Handler {
|
func (h *Handler) security(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'")
|
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'")
|
||||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
w.Header().Set("Referrer-Policy", "same-origin")
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
w.Header().Set("X-Frame-Options", "DENY")
|
w.Header().Set("X-Frame-Options", "DENY")
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ func TestListRendersEscapedTodos(t *testing.T) {
|
|||||||
if strings.Contains(recorder.Body.String(), "<script>") || !strings.Contains(recorder.Body.String(), "<script>") {
|
if strings.Contains(recorder.Body.String(), "<script>") || !strings.Contains(recorder.Body.String(), "<script>") {
|
||||||
t.Fatalf("title was not escaped: %s", recorder.Body.String())
|
t.Fatalf("title was not escaped: %s", recorder.Body.String())
|
||||||
}
|
}
|
||||||
|
if got := recorder.Header().Get("Referrer-Policy"); got != "same-origin" {
|
||||||
|
t.Fatalf("Referrer-Policy = %q, want %q", got, "same-origin")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateValidatesAndTrimsTitle(t *testing.T) {
|
func TestCreateValidatesAndTrimsTitle(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user