--- # Production deploy for toolshed. # # - installs docker.io and nginx on the target (plus certbot, unless # behind_tls_proxy is true) # - checks out the source and builds the backend and frontend docker images # - runs the frontend image once to export its static build, which nginx # then serves directly (the frontend image is never run as a service) # - configures nginx (inline template, no separate .conf file) and, unless # behind_tls_proxy is true, obtains/renews a Let's Encrypt certificate via # certbot and switches nginx over to it automatically - no manual TLS step # - installs and manages a systemd service that runs the backend container # # Usage (each host is its own independent deployment - always target one # at a time, never the whole "toolshed" group in one run): # ansible-playbook -i inventory.yml playbook.yml --limit my-server # # toolshed_repo_url, toolshed_domain, toolshed_handle_domain (optional), # toolshed_version (optional, defaults to "stable"), behind_tls_proxy and # toolshed_letsencrypt_email (required unless behind_tls_proxy is true) are # per-deployment and must be set as host_vars in inventory.yml (copy # inventory.example.yml) rather than here or via -e, so that each host in # the "toolshed" group can point at its own repo/domain/branch. They're read # with `mandatory`/`default()` below instead of being declared in play # `vars:`, since play vars always take precedence over inventory host_vars # and would otherwise silently override whatever is set per-host. - name: Deploy toolshed hosts: toolshed become: true vars: toolshed_src_dir: /opt/toolshed/src toolshed_data_dir: /opt/toolshed/data toolshed_dist_dir: /var/www/toolshed toolshed_backend_image: toolshed-backend toolshed_frontend_image: toolshed-frontend-builder toolshed_wiki_image: toolshed-wiki-builder toolshed_backend_container: toolshed-backend toolshed_backend_port: 8000 toolshed_wiki_dist_dir: /var/www/toolshed-wiki toolshed_local_dir: /var/www/toolshed-local # Django's collectstatic output (admin/drf-yasg assets etc.), exported # from the built backend image so nginx can serve it directly instead of # proxying to gunicorn for every asset request. toolshed_static_dir: /var/www/toolshed-static # Domain(s) this server accepts registrations for (the "handle domain" - # see the README's DNS section). Served as a static /local/domains # fixture that the frontend's registration/pairing forms fetch to # populate their domain dropdown (frontend/src/views/Register.vue, # Pairing.vue) - without it that dropdown is just empty. toolshed_register_domains: "{{ [toolshed_handle_domain | default(toolshed_domain)] | unique }}" # DoH resolvers the frontend falls back to for SRV lookups when it has # no cached preference yet, served as a static /local/dns fixture. These # match the frontend's own hardcoded fallback (frontend/src/dns.js), so # this mostly makes the choice explicit and per-host overridable (e.g. # -e doh_resolvers='["9.9.9.9"]') rather than changing behavior. toolshed_doh_resolvers: "{{ doh_resolvers | default(['1.1.1.1', '8.8.8.8']) }}" # Docker tags can't contain "/", but toolshed_version is a git ref and # branch names like "jedi/proto/frontend" do - sanitize before using it # as an image tag. The raw value is still used as-is for the actual git # checkout, where slashes are fine. toolshed_image_tag: "{{ (toolshed_version | default('stable')) | replace('/', '-') }}" toolshed_debug: "False" # Plain HTTP listen port. Only relevant behind an external proxy that # forwards to something other than 80 (see http_port in inventory.yml); # when this nginx terminates TLS itself, the public port is always 443. toolshed_http_port: "{{ http_port | default(80) }}" toolshed_letsencrypt_webroot: /var/www/letsencrypt # Nginx sets its own X-Forwarded-Proto from $scheme when it terminates # TLS itself. Behind an external TLS-terminating proxy, $scheme at this # nginx is always "http" (the proxy already stripped TLS one hop # earlier), so overwriting the header with $scheme would tell Django # every request is insecure. In that case pass through the proxy's own # header instead. toolshed_x_forwarded_proto: >- {{ '$http_x_forwarded_proto' if (behind_tls_proxy | default(false) | bool) else '$scheme' }} # Only the web domain (toolshed_domain) - nginx server_name, Django # ALLOWED_HOSTS, and the cert certbot requests. The handle domain # (toolshed_handle_domain) is resolved by clients via its own SRV record # and doesn't necessarily have an A record pointing at this host at all # (see the README's DNS section), so it can't reliably serve an HTTP-01 # challenge or ever show up as this nginx's Host header. toolshed_hostnames: >- {{ [toolshed_domain | mandatory('toolshed_domain must be set as a host_var for ' ~ inventory_hostname)] }} # Generated once per host on the controller and reused on every # subsequent run against that host, keyed by inventory_hostname so # separate deployments never end up sharing a Django SECRET_KEY. toolshed_secret_key: >- {{ lookup('ansible.builtin.password', playbook_dir ~ '/.secrets/' ~ inventory_hostname ~ '_secret_key length=64 chars=ascii_letters,digits') }} # Rendered twice against the same var (see the tasks below): once before # a certificate exists (serves the site plainly over toolshed_http_port, # or over 80/plain-HTTP forever if behind_tls_proxy), and once after # certbot has obtained one, at which point the plain HTTP vhost switches # to a redirect and a 443 vhost with the real content appears. Whichever # of those two states applies, toolshed_cert (a registered `stat` result, # undefined/false until it's checked) decides which one renders - this # is the "another nginx config" from a single inline template, driven by # behind_tls_proxy and certificate state rather than a separate file. toolshed_nginx_conf: | upstream toolshed_backend { server 127.0.0.1:{{ toolshed_backend_port }}; } {% macro toolshed_locations() %} location /api { 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 {{ toolshed_x_forwarded_proto }}; proxy_pass http://toolshed_backend; } location /auth { 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 {{ toolshed_x_forwarded_proto }}; proxy_pass http://toolshed_backend; } # Django (SignatureAuthentication + per-file friend/owner checks, # see files/media_urls.py) decides whether the request is allowed # at all; it never streams the bytes itself here (SERVE_X_ACCEL_REDIRECT # is on), it just answers with an X-Accel-Redirect to the internal # location below, which nginx follows and serves directly from disk. location /media { 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 {{ toolshed_x_forwarded_proto }}; proxy_pass http://toolshed_backend; } # Only reachable via the X-Accel-Redirect above, never directly by # clients (`internal`) - this is what makes it safe for nginx to # serve these bytes itself without reimplementing the access # checks Django already did in the /media location. location /redirect_media/ { internal; alias {{ toolshed_data_dir }}/userfiles/; # Django would normally set this itself (CORS_ALLOW_ALL_ORIGINS, # see settings.py) but never gets to run for a request nginx # serves directly - see the comment in files/media_urls.py. add_header Access-Control-Allow-Origin * always; } location /djangoadmin { 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 {{ toolshed_x_forwarded_proto }}; proxy_pass http://toolshed_backend; } location /docs { 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 {{ toolshed_x_forwarded_proto }}; proxy_pass http://toolshed_backend; } location /static/ { alias {{ toolshed_static_dir }}/; try_files $uri =404; } location /wiki/ { alias {{ toolshed_wiki_dist_dir }}/; try_files $uri $uri/ =404; } location = /wiki { return 301 /wiki/; } # Static fixtures the frontend fetches directly (registration # domain list, DoH resolver preference) - see toolshed_register_domains # and toolshed_doh_resolvers above. location /local/ { alias {{ toolshed_local_dir }}/; try_files $uri.json =404; add_header Content-Type application/json; } # Vue-router history mode: fall back to index.html for # any path that isn't a real static file. location / { try_files $uri $uri/ /index.html; } {% endmacro %} {% if behind_tls_proxy | default(false) | bool %} server { listen {{ toolshed_http_port }}; listen [::]:{{ toolshed_http_port }}; server_name {{ toolshed_hostnames | join(' ') }}; client_max_body_size 128M; root {{ toolshed_dist_dir }}; index index.html; {{ toolshed_locations() }} } {% else %} {% set tls_active = toolshed_cert.stat.exists | default(false) %} server { listen {{ toolshed_http_port }}; listen [::]:{{ toolshed_http_port }}; server_name {{ toolshed_hostnames | join(' ') }}; location /.well-known/acme-challenge/ { root {{ toolshed_letsencrypt_webroot }}; } {% if tls_active %} location / { return 301 https://$host$request_uri; } {% else %} client_max_body_size 128M; root {{ toolshed_dist_dir }}; index index.html; {{ toolshed_locations() }} {% endif %} } {% if tls_active %} server { listen 443 ssl; listen [::]:443 ssl; server_name {{ toolshed_hostnames | join(' ') }}; ssl_certificate /etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/{{ toolshed_domain }}/privkey.pem; client_max_body_size 128M; root {{ toolshed_dist_dir }}; index index.html; {{ toolshed_locations() }} } {% endif %} {% endif %} tasks: - name: Install docker.io and nginx ansible.builtin.apt: name: - docker.io - nginx state: present update_cache: true - name: Install certbot ansible.builtin.apt: name: certbot state: present when: not (behind_tls_proxy | default(false) | bool) - name: Ensure docker is running and enabled ansible.builtin.systemd: name: docker state: started enabled: true - name: Ensure nginx is running and enabled ansible.builtin.systemd: name: nginx state: started enabled: true - name: Checkout toolshed source ansible.builtin.git: repo: "{{ toolshed_repo_url | mandatory('toolshed_repo_url must be set as a host_var for ' ~ inventory_hostname) }}" dest: "{{ toolshed_src_dir }}" version: "{{ toolshed_version | default('stable') }}" force: true # frontend/extras is registered as a submodule but unused and its # pinned commit isn't fetchable from upstream - don't let a broken # submodule block the checkout. recursive: false - name: Create toolshed system user ansible.builtin.user: name: toolshed system: true shell: /usr/sbin/nologin home: "{{ toolshed_data_dir }}" create_home: false register: toolshed_user - name: Create backend data directories ansible.builtin.file: path: "{{ item }}" state: directory owner: toolshed group: toolshed mode: "0750" loop: - "{{ toolshed_data_dir }}" - "{{ toolshed_data_dir }}/userfiles" # nginx's `location /redirect_media/` (below) reads user-uploaded files # straight off disk as www-data - group membership plus the 0750 mode # above/FILE_UPLOAD_PERMISSIONS (backend/backend/settings.py) is what # makes that readable without loosening it to world-readable. - name: Allow nginx to read backend user files ansible.builtin.user: name: www-data groups: toolshed append: true # New group membership only takes effect for processes started (or # forked) after this - nginx's already-running workers won't see it # until reloaded. notify: reload nginx - name: Create frontend static output directory ansible.builtin.file: path: "{{ toolshed_dist_dir }}" state: directory owner: www-data group: www-data mode: "0755" - name: Create backend static output directory ansible.builtin.file: path: "{{ toolshed_static_dir }}" state: directory owner: www-data group: www-data mode: "0755" - name: Write backend environment file ansible.builtin.copy: dest: "{{ toolshed_data_dir }}/backend.env" # Root-owned and unreadable by the toolshed user on purpose: this is # read by the docker daemon (root) via --env-file at container # start and injected directly as env vars, so the containerized app # - which runs as the toolshed user, see the systemd unit below - # never needs filesystem access to its own SECRET_KEY. owner: root group: root mode: "0600" content: | DEBUG={{ toolshed_debug }} SECRET_KEY={{ toolshed_secret_key }} ALLOWED_HOSTS={{ toolshed_hostnames | join(',') }} SERVE_X_ACCEL_REDIRECT=True TOOLSHED_DB_PATH=/data/db.sqlite3 TOOLSHED_USERFILES_PATH=/data/userfiles notify: restart backend - name: Build backend docker image ansible.builtin.command: cmd: >- docker build -t {{ toolshed_backend_image }}:{{ toolshed_image_tag }} -f {{ toolshed_src_dir }}/deploy/prod/Dockerfile.backend {{ toolshed_src_dir }}/backend changed_when: true notify: restart backend - name: Tag backend image as latest ansible.builtin.command: cmd: docker tag {{ toolshed_backend_image }}:{{ toolshed_image_tag }} {{ toolshed_backend_image }}:latest changed_when: true notify: restart backend # Dockerfile.backend runs collectstatic at build time, baking the result # into the image at /app/staticfiles - copy it out to the host so nginx # can serve it directly instead of proxying every asset request to # gunicorn. No Django settings/DB access needed, so this can run as a # one-off command against the image rather than the container. - name: Export backend static files ansible.builtin.command: cmd: >- docker run --rm -v {{ toolshed_static_dir }}:/output {{ toolshed_backend_image }}:latest sh -c "cp -a /app/staticfiles/. /output/" changed_when: true - name: Fix ownership of exported backend static files ansible.builtin.file: path: "{{ toolshed_static_dir }}" owner: www-data group: www-data recurse: true - name: Install systemd unit for the backend container ansible.builtin.copy: dest: /etc/systemd/system/toolshed-backend.service owner: root group: root mode: "0644" content: | [Unit] Description=Toolshed backend (Django) container After=docker.service network-online.target Requires=docker.service Wants=network-online.target [Service] TimeoutStartSec=0 Restart=always ExecStartPre=-/usr/bin/docker stop {{ toolshed_backend_container }} ExecStartPre=-/usr/bin/docker rm {{ toolshed_backend_container }} ExecStart=/usr/bin/docker run --rm --name {{ toolshed_backend_container }} \ --user {{ toolshed_user.uid }}:{{ toolshed_user.group }} \ --env-file {{ toolshed_data_dir }}/backend.env \ -v {{ toolshed_data_dir }}:/data \ -p 127.0.0.1:{{ toolshed_backend_port }}:8000 \ {{ toolshed_backend_image }}:latest ExecStop=/usr/bin/docker stop {{ toolshed_backend_container }} [Install] WantedBy=multi-user.target notify: restart backend # Installed before the bootstrap nginx flush_handlers below (which # flushes every pending handler, not just reload nginx) - otherwise a # fresh host would flush "restart backend" before this unit file exists # and fail with "Could not find the requested service". - name: Ensure toolshed-backend service is enabled and started ansible.builtin.systemd: name: toolshed-backend daemon_reload: true enabled: true state: started - name: Build frontend builder docker image ansible.builtin.command: cmd: >- docker build -t {{ toolshed_frontend_image }}:{{ toolshed_image_tag }} -f {{ toolshed_src_dir }}/deploy/prod/Dockerfile.frontend {{ toolshed_src_dir }}/frontend changed_when: true - name: Run frontend builder once to export the static build ansible.builtin.command: cmd: docker run --rm -v {{ toolshed_dist_dir }}:/output {{ toolshed_frontend_image }}:{{ toolshed_image_tag }} changed_when: true - name: Fix ownership of exported frontend build ansible.builtin.file: path: "{{ toolshed_dist_dir }}" owner: www-data group: www-data recurse: true - name: Create wiki static output directory ansible.builtin.file: path: "{{ toolshed_wiki_dist_dir }}" state: directory owner: www-data group: www-data mode: "0755" - name: Build wiki builder docker image ansible.builtin.command: cmd: >- docker build -t {{ toolshed_wiki_image }}:{{ toolshed_image_tag }} -f {{ toolshed_src_dir }}/deploy/prod/Dockerfile.wiki {{ toolshed_src_dir }} changed_when: true - name: Run wiki builder once to export the static site ansible.builtin.command: cmd: docker run --rm -v {{ toolshed_wiki_dist_dir }}:/output {{ toolshed_wiki_image }}:{{ toolshed_image_tag }} changed_when: true - name: Fix ownership of exported wiki build ansible.builtin.file: path: "{{ toolshed_wiki_dist_dir }}" owner: www-data group: www-data recurse: true - name: Create local fixtures directory ansible.builtin.file: path: "{{ toolshed_local_dir }}" state: directory owner: www-data group: www-data mode: "0755" - name: Write registration domain list fixture ansible.builtin.copy: dest: "{{ toolshed_local_dir }}/domains.json" owner: www-data group: www-data mode: "0644" content: "{{ toolshed_register_domains | to_nice_json }}" - name: Write DoH resolver fixture ansible.builtin.copy: dest: "{{ toolshed_local_dir }}/dns.json" owner: www-data group: www-data mode: "0644" content: "{{ toolshed_doh_resolvers | to_nice_json }}" - name: Create ACME HTTP-01 challenge webroot ansible.builtin.file: path: "{{ toolshed_letsencrypt_webroot }}" state: directory owner: www-data group: www-data mode: "0755" when: not (behind_tls_proxy | default(false) | bool) - name: Check for an existing Let's Encrypt certificate ansible.builtin.stat: path: "/etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem" register: toolshed_cert when: not (behind_tls_proxy | default(false) | bool) - name: Configure nginx site for toolshed (bootstrap) ansible.builtin.copy: dest: /etc/nginx/sites-available/toolshed.conf owner: root group: root mode: "0644" content: "{{ toolshed_nginx_conf }}" notify: reload nginx - name: Remove default nginx site ansible.builtin.file: path: /etc/nginx/sites-enabled/default state: absent notify: reload nginx - name: Enable toolshed nginx site ansible.builtin.file: src: /etc/nginx/sites-available/toolshed.conf dest: /etc/nginx/sites-enabled/toolshed.conf state: link notify: reload nginx # Certbot's webroot check (below) needs nginx already serving the # bootstrap config from the tasks above, so force the reload now # instead of waiting for the end of the play. - name: Apply the bootstrap nginx config now ansible.builtin.meta: flush_handlers - name: Ensure the certbot renewal deploy-hook directory exists ansible.builtin.file: path: /etc/letsencrypt/renewal-hooks/deploy state: directory mode: "0755" when: not (behind_tls_proxy | default(false) | bool) - name: Reload nginx after certbot renews a certificate ansible.builtin.copy: dest: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh owner: root group: root mode: "0755" content: | #!/bin/sh systemctl reload nginx when: not (behind_tls_proxy | default(false) | bool) - name: Obtain or renew the Let's Encrypt certificate ansible.builtin.command: cmd: >- certbot certonly --webroot -w {{ toolshed_letsencrypt_webroot }} -d {{ toolshed_hostnames | join(' -d ') }} --non-interactive --agree-tos -m {{ toolshed_letsencrypt_email | mandatory('toolshed_letsencrypt_email must be set as a host_var for ' ~ inventory_hostname ~ ' since behind_tls_proxy is false there') }} register: toolshed_certbot changed_when: "'Certificate not yet due for renewal' not in toolshed_certbot.stdout" when: not (behind_tls_proxy | default(false) | bool) - name: Re-check the certificate now that certbot has run ansible.builtin.stat: path: "/etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem" register: toolshed_cert when: not (behind_tls_proxy | default(false) | bool) - name: Configure nginx site for toolshed (final) ansible.builtin.copy: dest: /etc/nginx/sites-available/toolshed.conf owner: root group: root mode: "0644" content: "{{ toolshed_nginx_conf }}" notify: reload nginx handlers: - name: validate nginx config ansible.builtin.command: nginx -t listen: reload nginx changed_when: false - name: reload nginx ansible.builtin.systemd: name: nginx state: reloaded listen: reload nginx - name: restart backend ansible.builtin.systemd: name: toolshed-backend daemon_reload: true state: restarted listen: restart backend