├── README.md ├── group_vars └── all.yml ├── hosts ├── playbook.yml └── roles └── node-servers ├── handlers └── main.yml ├── tasks ├── daemonize-nodeapp.yml ├── deploy.yml ├── haproxy.yml ├── iptables.yml ├── letsencrypt-dns.yml ├── main.yml └── nodejs.yml └── templates ├── haproxy.j2 └── nodeapp.j2 /README.md: -------------------------------------------------------------------------------- 1 | # NodeJS Server: Digital Ocean Ansible Playbook 2 | 3 | ![Network Architecutre](https://cdn-images-1.medium.com/max/800/1*TvGvibYBKv3bfMHAUrhJKg.png) 4 | 5 | **Deploy a NodeJS application to Digital Ocean with ease!** Don't have a Digital Ocean account? [Get one here and get $10 free](https://m.do.co/c/dde4646baa31) (enough to pay for 2 servers for a month). 6 | 7 | This play will set up a single server that is well suited to small/medium NodeJS applications on Digital Ocean. I use this same playbook for setting A/B testing for my marketing campaigns and have used it on servers that recieve large traffic loads without any issues. 8 | 9 | The playbook is an extension to a [tutorial I write](https://codeburst.io/building-a-nodejs-web-server-with-haproxy-and-lets-encrypt-on-debian-stretch-2fbf16cfba3a) on setting up a small production ready secure NodeJS server. It performs exactly the same tasks detailed in my tutorial, except it automates the entire process using Ansible. 10 | 11 | ## Feautres: 12 | 13 | * NodeJS 8 14 | * HAProxy 15 | * Automatic application deployment 16 | * Let's Encrypt dns-01 domain verification and certificate renewal 17 | * Daemonized NodeJS application using `systemd` 18 | * Secured Firewall (Only ports 22, 80 and 443 are open) 19 | 20 | ## How It Works 21 | 22 | In this playbook the node application lives under `/var/www`. The default entry point for the application is `/var/www/server.js`. If your entry point differs this can be changed in `group_vars/all.yml`. 23 | 24 | I use `haproxy` to act like a revere proxy that load balances 3 instances of `server.createServer`, each running on port `5001`, `5002` and `5003` respectively. Ideally your application should do the same to avoid having to make configuration changes to `haproxy.j2` and `nodeapp.j2`. If you know what you're doing and would like to make modifications for a more advanced architecture you will need to edit `roles/templates/haproxy.j2` and `roles/templates/nodeapp.j2`. 25 | 26 | HTTPS is enabled by default. SSL/TLS certificates are provided by Let's Encrypt using `dns-01` and auto certificate renewal. 27 | 28 | ## Prequisites 29 | 30 | * Ansible 2.4 31 | * A Digital Ocean account 32 | * A domain name 33 | 34 | This playbook requires Ansible version 2.4. As of writing this is currently the development version. For instructions on installing 2.4 [see here](http://docs.ansible.com/ansible/intro_installation.html#running-from-source). 35 | 36 | ## Configuring Playbook 37 | 38 | Before running the playbook a couple of configuration options need to be set. Configuration options are stored in `group_vars/all.yml`. An example configuration is below: 39 | 40 | 41 | ``` 42 | # Domain name for your application 43 | domain: yourdomain.com 44 | 45 | # Git repository for your NodeJS application 46 | app_repo: https://github.com/JamesTheHacker/nodebox-testapp 47 | 48 | # Application entry point 49 | entry_point: server.js 50 | 51 | # Digital Ocean API key 52 | api_token: 308ddfb93a32a22ef222de98496e981ef247d5c1f6fe17d76d8f9db30a7d5f23 53 | 54 | # SSH key id (fingerprint) to existing SSH key on Digital Ocean 55 | ssh_key_id: xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx 56 | 57 | # Unprivileged username and password 58 | user: web 59 | user_pass: ChangeThisPasswordToSomethingSecure 60 | 61 | # Your email address. Required for Let's Encrypt 62 | email: your@email.com 63 | 64 | # 2 character country code. Required for Let's Encrypt 65 | country: GB 66 | ``` 67 | 68 | **Variables** 69 | 70 | * `domain:`: Set this to the domain name you want to link to the application. Do not include `www.`. Before running the playbook update the domains nameservers to: `ns1.digitalocean.com`, `ns2.digitalocean.com` and `ns3.digitalocean.com`. 71 | * `app_repo`: The playbook will automatically clone an application from a git repository (Github, Bitbucket etc). 72 | * `entry_point`: Entry point for your NodeJS application 73 | * `api_token`: Set this to your Digital Ocean API token. You can generate a token [here](https://cloud.digitalocean.com/settings/api/tokens). 74 | * `ssh_key_id`: This should contain the SSH fingerprint from Digital Ocean. You can find the fingerprint [here](https://cloud.digitalocean.com/settings/security) 75 | * `user`: Unprivilaged username 76 | * `password`: Unprivilaged user password 77 | * `email`: A valid email address. Required for Let's Encrypt 78 | * `country`: 2 digit country code. Required for Let's Encrypt 79 | 80 | **Note:** I recommend using [Vault](http://docs.ansible.com/ansible/playbooks_vault.html) to encrypt `all.yml` to ensure passwords are not stored in plain text. 81 | 82 | ## Go! 83 | 84 | Setting up the server and deploying your application is simple! Once you've set the required configuration variables simple run the following: 85 | 86 | cd nodejs-server-ansible-playbook 87 | ansible-playbook playbook.yml 88 | 89 | If all runs successfully you should now be able to visit your domain in the browser and see your application running :) 90 | 91 | ## Help and Support 92 | 93 | If you have an problems please file an issue. You can also catch me in the `#ansible` channel on freenode (username `jj15`), or tweet me at [@JamesTheHaxor](http://twitter.com/JamesTheHaxor) 94 | 95 | ## Contributions 96 | 97 | I'm human, and mistakes/errors/issues happen. If you would like to fix any issues, or improve this playbook please submit a pull request and I will happily merge :) 98 | 99 | ## Shoutouts 100 | 101 | I'd like to thank my good friend [@Radar](https://twitter.com/MichaelCRaeder) for his help and support. I would also like to thank the amazing people in the `#ansible` channel for answering my numerous questions. You guys are awesome! 102 | -------------------------------------------------------------------------------- /group_vars/all.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # Domain name for your application 4 | domain: 5 | 6 | # Git repository for your NodeJS application 7 | app_repo: https://github.com/JamesTheHacker/nodebox-testapp 8 | 9 | # Application entry point 10 | entry_point: server.js 11 | 12 | # Digital Ocean API key 13 | api_token: 14 | 15 | # An SSH key added to Digital Ocean 16 | ssh_key_id: 17 | 18 | # Unprivileged user 19 | user: web 20 | user_pass: ProBa92BBL23eeN034444Se12340cUR203e4124daade 21 | 22 | # The following variables are required for Let's Encrypt 23 | email: your@email.com 24 | country: GB -------------------------------------------------------------------------------- /hosts: -------------------------------------------------------------------------------- 1 | [digitalocean] 2 | localhost ansible_connection=local -------------------------------------------------------------------------------- /playbook.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: localhost 3 | tasks: 4 | - name: Spinning up a new droplet 5 | digital_ocean: 6 | state: present 7 | command: droplet 8 | name: "{{ domain }}" 9 | ssh_key_ids: 10 | - "{{ ssh_key_id }}" 11 | size_id: 512mb 12 | region_id: lon1 13 | image_id: debian-9-x64 14 | api_token: "{{ api_token }}" 15 | backups_enabled: no 16 | register: server 17 | 18 | - name: Adding domain name {{ domain }} 19 | digital_ocean_domain: 20 | api_token: "{{ api_token }}" 21 | state: present 22 | name: "{{ domain }}" 23 | ip: "{{ server.droplet.ip_address }}" 24 | 25 | - name: Adding www A record 26 | uri: 27 | url: https://api.digitalocean.com/v2/domains/{{ domain }}/records 28 | method: POST 29 | status_code: 201 30 | headers: 31 | Content-Type: application/json 32 | Authorization: Bearer {{ api_token }} 33 | body: 34 | type: A 35 | name: www 36 | data: "{{ server.droplet.ip_address }}" 37 | priority: null 38 | port: null 39 | ttl: 1800 40 | weight: null 41 | body_format: json 42 | 43 | - name: Adding droplet to hosts 44 | add_host: hostname={{ server.droplet.ip_address }} group=node-servers 45 | 46 | - name: Wait for SSH to come up 47 | local_action: wait_for host={{ server.droplet.ip_address }} port=22 delay=20 timeout=320 state=started 48 | 49 | - hosts: node-servers 50 | remote_user: root 51 | 52 | vars: 53 | - www_domain: "www.{{ domain }}" 54 | 55 | roles: 56 | - node-servers 57 | -------------------------------------------------------------------------------- /roles/node-servers/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: restart-haproxy 3 | service: 4 | name: haproxy 5 | state: restarted -------------------------------------------------------------------------------- /roles/node-servers/tasks/daemonize-nodeapp.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Created nodeapp service 3 | template: 4 | src: templates/nodeapp.j2 5 | dest: /etc/systemd/system/nodeapp.service 6 | owner: root 7 | group: root 8 | 9 | - name: Adding service to boot 10 | systemd: 11 | enabled: yes 12 | name: nodeapp 13 | state: started -------------------------------------------------------------------------------- /roles/node-servers/tasks/deploy.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Cloning app repo 4 | git: 5 | repo: "{{ app_repo }}" 6 | dest: /var/www 7 | 8 | - name: Running npm install 9 | npm: 10 | path: /var/www -------------------------------------------------------------------------------- /roles/node-servers/tasks/haproxy.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install haproxy 3 | apt: name=haproxy state=latest 4 | 5 | - name: Creating /var/www directory 6 | file: 7 | path: /var/www 8 | state: directory 9 | owner: "{{ user }}" 10 | group: "{{ user }}" 11 | 12 | - name: Creating /etc/haproxy/certs directory 13 | file: 14 | path: /etc/haproxy/certs 15 | state: directory 16 | 17 | - name: Copying haproxy .cfg file 18 | template: 19 | src: templates/haproxy.j2 20 | dest: /etc/haproxy/haproxy.cfg 21 | owner: root 22 | group: root 23 | notify: 24 | - restart-haproxy -------------------------------------------------------------------------------- /roles/node-servers/tasks/iptables.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - ufw: 3 | rule: limit 4 | port: ssh 5 | proto: tcp 6 | 7 | - ufw: 8 | rule: allow 9 | port: 80 10 | proto: tcp 11 | 12 | - ufw: 13 | rule: allow 14 | port: 443 15 | proto: tcp 16 | 17 | - name: Enabling ufw 18 | ufw: 19 | state: enabled -------------------------------------------------------------------------------- /roles/node-servers/tasks/letsencrypt-dns.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # Required to store public key 4 | - name: Creating /etc/ssl/public directory 5 | file: 6 | path: /etc/ssl/public 7 | state: directory 8 | 9 | # Required to store csr 10 | - name: Creating /etc/ssl/csr directory 11 | file: 12 | path: /etc/ssl/csr 13 | state: directory 14 | 15 | # Generate an account key for letsencrypt cert. 16 | - name: Generating Let's Encrypt account key 17 | openssl_privatekey: 18 | path: "/etc/ssl/private/le_account.key" 19 | 20 | # Generate a domain key for letsencrypt cert. 21 | - name: Generating domain key 22 | openssl_privatekey: 23 | path: "/etc/ssl/private/{{ domain }}.key" 24 | 25 | - name: Generating Certificate Signing Request 26 | openssl_csr: 27 | path: "/etc/ssl/csr/{{ domain }}.csr" 28 | privatekey_path: "/etc/ssl/private/{{ domain }}.key" 29 | countryName: "{{ country }}" 30 | organizationName: "{{ domain }}" 31 | emailAddress: "{{ email }}" 32 | subjectAltName: "DNS:{{ www_domain }},DNS:{{ domain }}" 33 | 34 | - name: Preparing Let's Encrypt SSL Certificate 35 | letsencrypt: 36 | account_email: "{{ email }}" 37 | account_key: "/etc/ssl/private/le_account.key" 38 | acme_directory: https://acme-v01.api.letsencrypt.org/directory 39 | agreement: "https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf" 40 | challenge: dns-01 41 | csr: "/etc/ssl/csr/{{ domain }}.csr" 42 | dest: "/etc/ssl/private/{{ domain }}.crt" 43 | register: acme_data 44 | 45 | # Create a domain.com TXT record on Digital Ocean to validate domain for Let's Encrypt 46 | - name: Creating domain TXT record 47 | uri: 48 | url: https://api.digitalocean.com/v2/domains/{{ domain }}/records 49 | method: POST 50 | status_code: 201 51 | headers: 52 | Content-Type: application/json 53 | Authorization: Bearer {{ api_token }} 54 | body: 55 | type: TXT 56 | name: "{{ acme_data['challenge_data'][domain]['dns-01']['resource'] }}" 57 | data: "{{ acme_data['challenge_data'][domain]['dns-01']['resource_value'] }}" 58 | priority: null 59 | port: null 60 | ttl: 1800 61 | weight: null 62 | body_format: json 63 | when: acme_data | changed 64 | 65 | - name: Creating www domain TXT record 66 | uri: 67 | url: https://api.digitalocean.com/v2/domains/{{ domain }}/records 68 | method: POST 69 | status_code: 201 70 | headers: 71 | Content-Type: application/json 72 | Authorization: Bearer {{ api_token }} 73 | body: 74 | type: TXT 75 | name: "{{ acme_data['challenge_data'][www_domain]['dns-01']['resource'] }}.www" 76 | data: "{{ acme_data['challenge_data'][www_domain]['dns-01']['resource_value'] }}" 77 | priority: null 78 | port: null 79 | ttl: 1800 80 | weight: null 81 | body_format: json 82 | 83 | # Wait for DNS to propegate 84 | - pause: 85 | minutes: 1 86 | 87 | - name: Generating Let's Encrypt SSL Certificate 88 | letsencrypt: 89 | account_key: "/etc/ssl/private/le_account.key" 90 | acme_directory: https://acme-v01.api.letsencrypt.org/directory 91 | challenge: dns-01 92 | csr: "/etc/ssl/csr/{{ domain }}.csr" 93 | dest: "/etc/ssl/private/{{ domain }}.crt" 94 | data: "{{ acme_data }}" 95 | 96 | # I don't like this here. Move it out! 97 | - name: Creating PEM file 98 | shell: cat "/etc/ssl/private/{{ domain }}.crt" "/etc/ssl/private/{{ domain }}.key" | tee "/etc/haproxy/certs/{{ domain }}.pem" 99 | 100 | -------------------------------------------------------------------------------- /roles/node-servers/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Creating unprivileged user 3 | user: 4 | name: "{{ user }}" 5 | state: present 6 | password: "{{ user_pass }}" 7 | 8 | - name: Updating packages 9 | shell: apt-get update 10 | 11 | - name: Installing Required packages 12 | apt: name={{ item }} state=latest 13 | with_items: 14 | - build-essential 15 | - apt-transport-https 16 | - python-openssl 17 | - git 18 | - ufw 19 | 20 | - include: nodejs.yml 21 | - include: haproxy.yml 22 | - include: letsencrypt-dns.yml 23 | - include: deploy.yml 24 | - include: daemonize-nodeapp.yml 25 | - include: iptables.yml -------------------------------------------------------------------------------- /roles/node-servers/tasks/nodejs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Adding apt key for nodesource 4 | apt_key: url=https://deb.nodesource.com/gpgkey/nodesource.gpg.key 5 | 6 | - name: Adding repo for nodesource 7 | apt_repository: 8 | repo: 'deb https://deb.nodesource.com/node_8.x stretch main' 9 | update_cache: yes 10 | 11 | - name: Installing NodeJS 12 | apt: name=nodejs state=latest -------------------------------------------------------------------------------- /roles/node-servers/templates/haproxy.j2: -------------------------------------------------------------------------------- 1 | global 2 | log /dev/log local0 3 | log /dev/log local1 notice 4 | chroot /var/lib/haproxy 5 | stats socket /run/haproxy/admin.socket mode 660 level admin 6 | stats timeout 30s 7 | user haproxy 8 | group haproxy 9 | daemon 10 | maxconn 2048 11 | tune.ssl.default-dh-param 2048 12 | 13 | defaults 14 | log global 15 | mode http 16 | option httplog 17 | option dontlognull 18 | option forwardfor 19 | option http-server-close 20 | 21 | frontend www-http 22 | bind {{ hostvars.localhost.server.droplet.ip_address }}:80 23 | reqadd X-Forwarded-Proto:\ http 24 | default_backend www-backend 25 | 26 | frontend www-https 27 | bind {{ hostvars.localhost.server.droplet.ip_address }}:443 ssl crt /etc/haproxy/certs/{{ domain }}.pem 28 | reqadd X-Forwarded-Proto:\ https 29 | default_backend www-backend 30 | 31 | backend www-backend 32 | redirect scheme https if !{ ssl_fc } 33 | mode http 34 | balance roundrobin 35 | stick-table type ip size 200k expire 100m 36 | stick on src 37 | server www-1 127.0.0.1:5001 check 38 | server www-2 127.0.0.1:5002 check 39 | server www-3 127.0.0.1:5003 check -------------------------------------------------------------------------------- /roles/node-servers/templates/nodeapp.j2: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Daemonized NodeJS App 3 | After=network.target 4 | 5 | [Service] 6 | ExecStart=/usr/bin/node /var/www/{{ entry_point }} 7 | ExecReload=/bin/kill -HUP $MAINPID 8 | Restart=always 9 | StandardOutput=syslog 10 | StandardError=syslog 11 | SyslogIdentifier=nodeapp 12 | User={{ user }} 13 | Group={{ user }} 14 | Environment=PATH=/usr/bin:/usr/local/bin 15 | Environment=NODE_ENV=production 16 | WorkingDirectory=/var/www/ 17 | 18 | [Install] 19 | WantedBy=multi-user.target --------------------------------------------------------------------------------