diff --git a/.gitignore b/.gitignore index a96455a..64f3fcd 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ scripts/*.log design/ stitch_the_great_bear_microbrewery.zip +# Reference deployment template from another project +ansible-sample/ + # Editor / OS .DS_Store *.log diff --git a/Dockerfile b/Dockerfile index 41eec13..4736e73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,8 +25,7 @@ ENV NODE_ENV=production \ WORKDIR /app -RUN apk add --no-cache curl unzip \ - && addgroup -S app && adduser -S app -G app +RUN apk add --no-cache curl unzip # Site server build output COPY --from=build /app/.output ./.output @@ -49,11 +48,10 @@ RUN case "${TARGETARCH:-amd64}" in \ && unzip -o /tmp/pb.zip pocketbase -d /app \ && chmod +x /app/pocketbase \ && rm /tmp/pb.zip \ - && mkdir -p /data \ - && chown -R app:app /app /data + && mkdir -p /app/pb_data -USER app -VOLUME /data +# Runs as root so the Ansible-managed bind mount (/var/lib/greatbear/pb_data) +# is writable regardless of host-side ownership. EXPOSE 3000 8090 diff --git a/README.md b/README.md index 1367546..7648912 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ scripts/ run-all.mjs Container supervisor: PocketBase + site server seed.mjs Idempotent content seeder (create-if-missing by uid) create-collections.sh Dev helper used to build the schema migration -ansible/ Deployment playbook (roles: app, caddy) +ansible/ Deployment playbook (roles: network, greatbear, caddy) Dockerfile Multi-stage image build deploy.sh Build & push image to the private registry ``` @@ -81,7 +81,7 @@ cd app && npm run dev docker build -t greatbear:dev . docker run --rm -p 3000:3000 -p 8090:8090 \ -e PB_SUPERUSER_EMAIL=dev@greatbear.in -e PB_SUPERUSER_PASSWORD=devpass12345 \ - -v greatbear_dev_data:/data greatbear:dev + -v greatbear_dev_data:/app/pb_data greatbear:dev ``` ## Deploying @@ -97,29 +97,32 @@ Tags pushed: `registry.tanshu.com/tanshu/greatbear:latest` and `:`. ### 2. Deploy with Ansible -Requirements on the controller: `ansible` + `community.docker` collection -(`ansible-galaxy collection install community.docker`), SSH access to the host. - -Requirements on the host (`www.greatbear.in`): Docker and Caddy already installed. +Requirements on the controller: `ansible` + `community.docker` collection (see +`ansible/requirements.yml`). Requirements on the host: Docker with a Caddy **container** named +`caddy` (config at `/var/lib/caddy/conf/Caddyfile`). ```bash cd ansible -# edit group_vars/greatbear/vars.yml if needed (or encrypt it with ansible-vault) -ansible-playbook site.yml # add -i inventory/hosts.yml if not using ansible.cfg +ansible-galaxy install -r requirements.yml +ansible-playbook playbook.yml # targets inventory host `monoco` — edit playbook.yml if needed ``` -The playbook: +All deployment variables live in `ansible/vars/default.yml`. The playbook: -1. Logs into the registry on the host, pulls the image, and (re)creates the `greatbear` - container with ports bound to loopback (`127.0.0.1:3000`, `127.0.0.1:8090`) and a named - volume `greatbear_pb_data:/data` for the database. -2. Waits for PocketBase + site health checks. -3. Writes `/etc/caddy/caddy.d/greatbear.conf` with both site blocks and reloads Caddy: +1. Ensures the `greatbear_net` Docker network exists with the Caddy container attached. +2. Pulls `registry.tanshu.com/tanshu/greatbear:latest`, uploads `/var/lib/greatbear/.env`, + and (re)creates the `greatbear` container on that network with + `/var/lib/greatbear/pb_data` bind-mounted to `/app/pb_data`, then waits for its healthcheck. +3. Inserts a managed snippet into the shared Caddyfile and reloads Caddy via + `docker exec`: - - `www.greatbear.in` → `127.0.0.1:3000` (the site) - - `admin.greatbear.in` → `127.0.0.1:8090` (PocketBase API + admin UI) + - `www.greatbear.in` (+ apex `greatbear.in`) — frontend on port 3000, PocketBase + API/admin proxied under `/api/*` and `/_/*` + - `admin.greatbear.in` — redirects to the PocketBase admin UI -DNS records for both domains must point at the host; Caddy obtains TLS certificates +No host ports are published; Caddy reaches the containers over the Docker network. + +DNS records for all three hosts must point at the server; Caddy obtains TLS certificates automatically on first request. ## PocketBase admin @@ -141,5 +144,6 @@ are never overwritten by redeploys. Schema changes belong in `pb/migrations/`. | ----------------------- | ------------------------ | -------------------------------------- | | `PORT` | `3000` | Site server port | | `PB_URL` | `http://127.0.0.1:8090` | PocketBase URL used by the site server | +| `PB_DATA_DIR` | `/data` | PocketBase data directory (volume) | | `PB_SUPERUSER_EMAIL` | — | Initial admin (bootstrapped at start) | | `PB_SUPERUSER_PASSWORD` | — | Initial admin password | diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg deleted file mode 100644 index 4d8d361..0000000 --- a/ansible/ansible.cfg +++ /dev/null @@ -1,9 +0,0 @@ -[defaults] -inventory = inventory/hosts.yml -roles_path = roles -host_key_checking = True -retry_files_enabled = False -interpreter_python = auto_silent - -[ssh_connection] -pipelining = True diff --git a/ansible/files/.env.j2 b/ansible/files/.env.j2 new file mode 100644 index 0000000..c6a8246 --- /dev/null +++ b/ansible/files/.env.j2 @@ -0,0 +1,7 @@ +NODE_ENV=production +PORT=3000 +HOST=0.0.0.0 +PB_DATA_DIR=/app/pb_data +PB_URL=http://127.0.0.1:8090 +PB_SUPERUSER_EMAIL={{ pb_admin_email }} +PB_SUPERUSER_PASSWORD={{ pb_admin_password }} diff --git a/ansible/files/Caddyfile.j2 b/ansible/files/Caddyfile.j2 new file mode 100644 index 0000000..c8f0395 --- /dev/null +++ b/ansible/files/Caddyfile.j2 @@ -0,0 +1,22 @@ +{{ host }}{% if apex_host is defined and apex_host %}, {{ apex_host }}{% endif %} { + # PocketBase Admin UI and REST API + redir /_ /_/ + handle /_/* { + reverse_proxy {{ container_name }}:8090 + } + handle /api/* { + reverse_proxy {{ container_name }}:8090 + } + + # TanStack Start Frontend Application + handle { + reverse_proxy {{ container_name }}:3000 + } +} + +{% if admin_host is defined and admin_host %} +{{ admin_host }} { + redir / /_/ + reverse_proxy {{ container_name }}:8090 +} +{% endif %} diff --git a/ansible/group_vars/greatbear/vars.yml b/ansible/group_vars/greatbear/vars.yml deleted file mode 100644 index d9d56d5..0000000 --- a/ansible/group_vars/greatbear/vars.yml +++ /dev/null @@ -1,31 +0,0 @@ ---- -# Deployment variables for The Great Bear. -# NOTE: This file contains credentials. For anything beyond a private homelab -# repo, encrypt it with: ansible-vault encrypt group_vars/greatbear/vars.yml - -# --- Domains (Caddy provisions TLS for both automatically) --- -site_domain: www.greatbear.in -admin_domain: admin.greatbear.in - -# --- Container image --- -registry_url: registry.tanshu.com -registry_username: ta-registry -registry_password: ff28a01f00c0f39315d94cd9dcb1e554968dba25676a8ea5f2be34e96a9a099f -app_image: registry.tanshu.com/tanshu/greatbear -app_image_tag: latest - -# --- Container runtime --- -app_container_name: greatbear -app_volume_name: greatbear_pb_data -# Ports are bound to loopback only; Caddy proxies public traffic to them. -app_site_publish: 127.0.0.1:3000:3000 -app_admin_publish: 127.0.0.1:8090:8090 - -# --- PocketBase initial admin (idempotent bootstrap on container start) --- -pb_superuser_email: admin@greatbear.in -pb_superuser_password: GreatBear!Admin2024 - -# --- Caddy --- -caddy_config_dir: /etc/caddy/caddy.d -caddyfile_path: /etc/caddy/Caddyfile -caddy_service: caddy diff --git a/ansible/inventory/hosts.yml b/ansible/inventory/hosts.yml deleted file mode 100644 index 11c19d6..0000000 --- a/ansible/inventory/hosts.yml +++ /dev/null @@ -1,9 +0,0 @@ -all: - children: - greatbear: - hosts: - www.greatbear.in: - ansible_user: root - # ansible_ssh_private_key_file: ~/.ssh/id_ed25519 - # Override ansible_user if you log in as a non-root sudo user: - # ansible_user: ubuntu diff --git a/ansible/playbook.yml b/ansible/playbook.yml new file mode 100644 index 0000000..ac8654c --- /dev/null +++ b/ansible/playbook.yml @@ -0,0 +1,11 @@ +--- +- name: Deploy The Great Bear Microbrewery + hosts: monoco + become: true + vars_files: + - vars/default.yml + + roles: + - network + - greatbear + - caddy diff --git a/ansible/requirements.yml b/ansible/requirements.yml new file mode 100644 index 0000000..660f775 --- /dev/null +++ b/ansible/requirements.yml @@ -0,0 +1,3 @@ +--- +collections: + - name: community.docker diff --git a/ansible/roles/app/tasks/main.yml b/ansible/roles/app/tasks/main.yml deleted file mode 100644 index 9c68b9a..0000000 --- a/ansible/roles/app/tasks/main.yml +++ /dev/null @@ -1,58 +0,0 @@ ---- -# Pull the image from the private registry and (re)create the app container. -- name: Log in to the private registry - community.docker.docker_login: - registry_url: "{{ registry_url }}" - username: "{{ registry_username }}" - password: "{{ registry_password }}" - -- name: Pull the application image - community.docker.docker_image: - name: "{{ app_image }}" - tag: "{{ app_image_tag }}" - source: pull - force_source: true - -- name: Run the application container - community.docker.docker_container: - name: "{{ app_container_name }}" - image: "{{ app_image }}:{{ app_image_tag }}" - state: started - recreate: true - restart_policy: unless-stopped - published_ports: - - "{{ app_site_publish }}" - - "{{ app_admin_publish }}" - volumes: - - "{{ app_volume_name }}:/data" - env: - PORT: "3000" - PB_URL: "http://127.0.0.1:8090" - PB_SUPERUSER_EMAIL: "{{ pb_superuser_email }}" - PB_SUPERUSER_PASSWORD: "{{ pb_superuser_password }}" - comparisons: - image: ignore # recreate: true already handles image updates - container_default_behavior: compatibility - -- name: Wait for PocketBase to become healthy - ansible.builtin.uri: - url: http://127.0.0.1:8090/api/health - status_code: 200 - register: pb_health - retries: 30 - delay: 5 - until: pb_health.status == 200 - -- name: Wait for the site to respond - ansible.builtin.uri: - url: http://127.0.0.1:3000/ - status_code: 200 - register: site_health - retries: 30 - delay: 5 - until: site_health.status == 200 - -- name: Log out of the private registry - community.docker.docker_login: - registry_url: "{{ registry_url }}" - state: absent diff --git a/ansible/roles/caddy/defaults/main.yaml b/ansible/roles/caddy/defaults/main.yaml new file mode 100644 index 0000000..259ebf2 --- /dev/null +++ b/ansible/roles/caddy/defaults/main.yaml @@ -0,0 +1,3 @@ +--- +caddy_container: caddy +caddy_caddyfile_path: /var/lib/caddy/conf/Caddyfile diff --git a/ansible/roles/caddy/handlers/main.yaml b/ansible/roles/caddy/handlers/main.yaml new file mode 100644 index 0000000..5fd9c8a --- /dev/null +++ b/ansible/roles/caddy/handlers/main.yaml @@ -0,0 +1,5 @@ +--- +- name: Reload Caddy configuration + ansible.builtin.command: "docker exec -w /etc/caddy {{ caddy_container }} caddy reload" + listen: "Reload Caddy" + changed_when: true diff --git a/ansible/roles/caddy/handlers/main.yml b/ansible/roles/caddy/handlers/main.yml deleted file mode 100644 index f608175..0000000 --- a/ansible/roles/caddy/handlers/main.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -- name: Reload Caddy - ansible.builtin.systemd: - name: "{{ caddy_service }}" - state: reloaded diff --git a/ansible/roles/caddy/tasks/main.yaml b/ansible/roles/caddy/tasks/main.yaml new file mode 100644 index 0000000..3fdaee9 --- /dev/null +++ b/ansible/roles/caddy/tasks/main.yaml @@ -0,0 +1,26 @@ +--- +- name: Read snippet from template file + ansible.builtin.set_fact: + caddy_snippet_block: "{{ lookup('template', 'files/Caddyfile.j2') }}" + +- name: Read current Caddyfile + ansible.builtin.slurp: + path: "{{ caddy_caddyfile_path }}" + register: caddy_caddyfile_raw + +- name: Decode Caddyfile content + ansible.builtin.set_fact: + caddy_caddyfile_content: "{{ caddy_caddyfile_raw['content'] | b64decode }}" + +- name: Check if snippet already exists + ansible.builtin.set_fact: + caddy_snippet_present: "{{ caddy_snippet_block in caddy_caddyfile_content }}" + +- name: Add or update snippet in Caddyfile + ansible.builtin.blockinfile: + path: "{{ caddy_caddyfile_path }}" + marker: "# {mark} Ansible managed Caddy snippet for {{ host }}" + block: "{{ caddy_snippet_block }}" + create: true + mode: "0644" + notify: Reload Caddy configuration diff --git a/ansible/roles/caddy/tasks/main.yml b/ansible/roles/caddy/tasks/main.yml deleted file mode 100644 index 6efed30..0000000 --- a/ansible/roles/caddy/tasks/main.yml +++ /dev/null @@ -1,47 +0,0 @@ ---- -# Manage Caddy entries for the site and the PocketBase admin/API host. -- name: Ensure Caddy config include directory exists - ansible.builtin.file: - path: "{{ caddy_config_dir }}" - state: directory - owner: root - group: root - mode: "0755" - -- name: Check whether the main Caddyfile exists - ansible.builtin.stat: - path: "{{ caddyfile_path }}" - register: caddyfile_stat - -- name: Ensure the main Caddyfile imports conf.d entries - ansible.builtin.lineinfile: - path: "{{ caddyfile_path }}" - line: "import {{ caddy_config_dir }}/*.conf" - insertafter: EOF - state: present - when: caddyfile_stat.stat.exists - notify: Reload Caddy - -- name: Install Great Bear Caddy entries - ansible.builtin.template: - src: greatbear.conf.j2 - dest: "{{ caddy_config_dir }}/greatbear.conf" - owner: root - group: root - mode: "0644" - notify: Reload Caddy - -- name: Validate Caddy configuration - ansible.builtin.command: - cmd: "caddy validate --config {{ caddyfile_path }} --adapter caddyfile" - changed_when: false - when: caddyfile_stat.stat.exists - -- name: Flush handlers (reload Caddy now) - ansible.builtin.meta: flush_handlers - -- name: Ensure Caddy is enabled and running - ansible.builtin.systemd: - name: "{{ caddy_service }}" - state: started - enabled: true diff --git a/ansible/roles/caddy/templates/greatbear.conf.j2 b/ansible/roles/caddy/templates/greatbear.conf.j2 deleted file mode 100644 index 1af1343..0000000 --- a/ansible/roles/caddy/templates/greatbear.conf.j2 +++ /dev/null @@ -1,12 +0,0 @@ -# The Great Bear — managed by Ansible. Changes will be overwritten. -# Frontend (TanStack Start) -{{ site_domain }} { - encode zstd gzip - reverse_proxy 127.0.0.1:3000 -} - -# PocketBase API + admin UI (http://{{ admin_domain }}/_/) -{{ admin_domain }} { - encode zstd gzip - reverse_proxy 127.0.0.1:8090 -} diff --git a/ansible/roles/greatbear/tasks/main.yaml b/ansible/roles/greatbear/tasks/main.yaml new file mode 100644 index 0000000..4166d00 --- /dev/null +++ b/ansible/roles/greatbear/tasks/main.yaml @@ -0,0 +1,56 @@ +--- +- name: Log in to private Docker registry + community.docker.docker_login: + registry: "{{ registry }}" + username: "{{ username }}" + password: "{{ password }}" + +- name: Pull The Great Bear image + community.docker.docker_image: + name: "{{ image_name }}" + source: pull + state: present + force_source: true + +- name: Ensure Host Directory exists + ansible.builtin.file: + path: "{{ host_data_path }}" + state: directory + mode: "0755" + +- name: Ensure PocketBase data directory exists + ansible.builtin.file: + path: "{{ host_data_path }}/pb_data" + state: directory + mode: "0755" + +- name: Upload the .env file + ansible.builtin.template: + src: "files/.env.j2" + dest: "{{ host_data_path }}/.env" + mode: "0600" + +- name: Create and run The Great Bear container + community.docker.docker_container: + name: "{{ container_name }}" + image: "{{ image_name }}" + state: started + restart_policy: "unless-stopped" + env_file: "{{ host_data_path }}/.env" + volumes: + - "{{ host_data_path }}/pb_data:/app/pb_data" + networks: + - name: "{{ docker_network }}" + +- name: Wait for the application container to become healthy + community.docker.docker_container_info: + name: "{{ container_name }}" + register: app_info + until: app_info.container.State.Health.Status == "healthy" + retries: 30 + delay: 5 + +- name: Log out of private Docker registry + community.docker.docker_login: + registry: "{{ registry }}" + state: absent diff --git a/ansible/roles/network/tasks/main.yaml b/ansible/roles/network/tasks/main.yaml new file mode 100644 index 0000000..f8d7781 --- /dev/null +++ b/ansible/roles/network/tasks/main.yaml @@ -0,0 +1,8 @@ +--- +- name: Ensure Docker network exists + community.docker.docker_network: + name: "{{ docker_network }}" + state: present + connected: + - "{{ caddy_container }}" + appends: true diff --git a/ansible/site.yml b/ansible/site.yml deleted file mode 100644 index dd04ff7..0000000 --- a/ansible/site.yml +++ /dev/null @@ -1,9 +0,0 @@ ---- -- name: Deploy The Great Bear (PocketBase + TanStack Start) - hosts: greatbear - become: true - roles: - - role: app - tags: [app, deploy] - - role: caddy - tags: [caddy, web] diff --git a/ansible/vars/default.yml b/ansible/vars/default.yml new file mode 100644 index 0000000..661da11 --- /dev/null +++ b/ansible/vars/default.yml @@ -0,0 +1,27 @@ +--- +registry: registry.tanshu.com +username: ta-registry +password: ff28a01f00c0f39315d94cd9dcb1e554968dba25676a8ea5f2be34e96a9a099f + +tag: latest + +image_name: "{{ registry }}/tanshu/greatbear:{{ tag }}" + +app_name: greatbear +host_directory: "{{ app_name }}" +host_data_path: "/var/lib/{{ host_directory }}" +container_name: "{{ app_name }}" +docker_network: "{{ app_name }}_net" + +# Public hosts for frontend & PocketBase routing +host: www.greatbear.in +apex_host: greatbear.in +admin_host: admin.greatbear.in + +# Caddy configuration +caddy_container: caddy +caddy_caddyfile_path: /var/lib/caddy/conf/Caddyfile + +# PocketBase Initial Superuser Credentials +pb_admin_email: admin@greatbear.in +pb_admin_password: GreatBear!Admin2024 diff --git a/deploy.sh b/deploy.sh index ea76cd0..689acaa 100755 --- a/deploy.sh +++ b/deploy.sh @@ -37,4 +37,4 @@ docker push "$IMAGE:$VERSION" echo echo "Done. Deploy with:" -echo " cd ansible && ansible-playbook -i inventory/hosts.yml site.yml" +echo " cd ansible && ansible-playbook playbook.yml" diff --git a/scripts/run-all.mjs b/scripts/run-all.mjs index 7194b4b..e8874ff 100755 --- a/scripts/run-all.mjs +++ b/scripts/run-all.mjs @@ -15,7 +15,9 @@ import { spawn, spawnSync } from 'node:child_process' const PORT = process.env.PORT ?? '3000' const PB_HTTP = process.env.PB_HTTP ?? '0.0.0.0:8090' -const PB_DATA = process.env.PB_DATA ?? '/data' +// PB_DATA_DIR is the canonical variable (set in the deploy .env); PB_DATA kept +// as a legacy alias, /data as the image default. +const PB_DATA = process.env.PB_DATA_DIR ?? process.env.PB_DATA ?? '/data' let stopping = false const children = []