1. Home

Anil Lakhman

< Blog />

Node, Nginx & PM2 stack template for AWS Cloudformation

in

This was a template I created with troposphere and launches an nginx and node.js (with PM2) stack on AWS via cloudformation.

It takes no parameters, but depends on the following Exports from another cloudformation stack.

ElasticIP
A export named ElasticIP (Domain Stack)
VPC
A export named VPC (VPC Stack) to add security groups
Subnet1
A export named Subnet1 (VPC Stack) to add our instance into

Note

  • I was testing this in the eu-west-2 (London) region, you may have to modify this to match your geographic region
  • To build this template use pip install troposphere and then run the file via python node-nginx-pm-stack.py
  • This uses PM2 to manage node processes.
  • The server root is /var/www/html

Important

This template depends on the following stacks.

Hint

Checkout the following log files if you’re having any issues.

  • cat /var/log/cloud-init-output.log
  • cd /var/lib/cloud/instances/**/
# -*- coding: utf-8 -*-
from __future__ import print_function
import troposphere.ec2 as ec2
import os
from troposphere import Base64, Join, ImportValue
from troposphere import Parameter, Ref, Tags, Template
from troposphere.ec2 import SecurityGroupRule, SecurityGroup

#
# Troposphere template - https://github.com/cloudtools/troposphere
#

#
# ┌───────────────────────────────────────────────────────────────────────────┐
# │                         Template & Parameters                             │
# └───────────────────────────────────────────────────────────────────────────┘
#
template = Template()
template.add_description("Node.js, Nginx stack w/PM | anil.io")


def make_name(component_name):
    return Join('', [component_name, ' - ', Ref('AWS::StackName')])


#
# Linux AMI ID - We build of a AWS AMI, create your own image and add it here
#
LinuxAmi = template.add_parameter(Parameter(
    "LinuxAmi",
    Type="AWS::EC2::Image::Id",
    Description="The AMI ID for our Linux Web server instance. (default: Amazon Linux AMI 2017 - ami-489f8e2c)",
    Default="ami-489f8e2c",
))

#
# ┌───────────────────────────────────────────────────────────────────────────┐
# │                               SSH Key pair                                │
# │     1) add keypair via AWS Console        2) reference it here            │
# └───────────────────────────────────────────────────────────────────────────┘
#
keyname_param = template.add_parameter(Parameter(
    "KeyPairName",
    Description="Name of an existing EC2 KeyPair to enable SSH access to the instance",
    Type="AWS::EC2::KeyPair::KeyName",
    Default="nld_aws_london"
))


#
# ┌───────────────────────────────────────────────────────────────────────────┐
# │                              Security Groups                              │
# │ When creating a Security Group, a default "Main" group is also created.   │
# │ We can ignore the empty group, we provide our own.                        │
# └───────────────────────────────────────────────────────────────────────────┘
#
instanceSecurityGroup = template.add_resource(
    SecurityGroup(
        'InstanceSecurityGroup',
        GroupDescription='Enable EC2 HTTP, HTTPS',
        SecurityGroupIngress=[
            # SSH
            SecurityGroupRule(IpProtocol='tcp', FromPort='22', ToPort='22', CidrIp='0.0.0.0/0'),

            # HTTP
            SecurityGroupRule(IpProtocol='tcp', FromPort='80', ToPort='80', CidrIp='0.0.0.0/0'),

            # HTTPS
            SecurityGroupRule(IpProtocol='tcp', FromPort='443', ToPort='443', CidrIp='0.0.0.0/0'),

            # Github - https://help.github.com/articles/what-ip-addresses-does-github-use-that-i-should-whitelist/
            # SecurityGroupRule(IpProtocol='tcp', FromPort='9418', ToPort='9418', CidrIp='192.30.252.0/22'),
        ],
        VpcId=ImportValue('VPC'),
        Tags=Tags(Name=make_name('Nginx, Node & PM'))
    )
)


#
# ┌───────────────────────────────────────────────────────────────────────────┐
# │                               EC2 Instance                                │
# └───────────────────────────────────────────────────────────────────────────┘
#
# We have 1 instance. Linux (Amazon Linux AMI) built from an AMI
# You should create and setup this instance manually and reference it here
#
# An Elastic IP (required to expose it publicly), must be launched within a
# Subnet with an IGW. To expose a server publicly we need to have one of:
#
#   a) An elastic IP
#   b) An Elastic Load Balancer serving traffic to our instance
#
# View UserData files and output here:
#   cd /var/lib/cloud/instances/**/
#   /var/log/cloud-init-output.log
#

# Get all the files we create
server_path = os.path.dirname(os.path.realpath(__file__))
with open(server_path + '/../includes/server.js', 'r') as serverjs:
    server_app = serverjs.read()

with open(server_path + '/../includes/pm2config.json', 'r') as pm2:
    pm2_config = pm2.read()

with open(server_path + '/../includes/nginx.conf', 'r') as nginx:
    nginx_conf = nginx.read()

# > = Replace entire file
# >> = Append (will create file if it doesn't exist)
user_data_server_app = """cat << EOF >> /var/www/html/example-app/server.js
%s
EOF
""" % server_app

user_data_pm2_config = """cat << EOF >> /var/www/html/example-app/pm2config.json
%s
EOF
""" % pm2_config

user_data_nginx_conf = """cat << EOF > /etc/nginx/nginx.conf
%s
EOF
""" % nginx_conf

user_data_nginx_index = """cat << EOF >> /var/www/html/index.html
<h1>Nginx server, running at <code>/var/www/html</code></h1>
EOF
"""

user_data_bashrc = """ cat << EOF >> /home/ec2-user/.bashrc

# User
export PATH=./bin:$PATH
alias l='ls -alh'

# .pm2
export USER=ec2-user
export HOME=/home/ec2-user
export PM2_HOME=/home/ec2-user/.pm2
EOF
"""

ec2_instance = template.add_resource(ec2.Instance(
    "Ec2Instance",
    ImageId=Ref(LinuxAmi),
    InstanceType="t2.small",
    KeyName=Ref(keyname_param),
    SecurityGroupIds=[Ref(instanceSecurityGroup)],
    SubnetId=ImportValue('Subnet1'),
    Tags=Tags(Name=make_name('Nginx, Node & PM')),
    UserData=Base64(Join('', [
        "#!/bin/bash\n",

        # Set the timezone - picked up on next reboot
        "sed -i '/ZONE=\"UTC\"/c\ZONE=\"Europe/London\"' /etc/sysconfig/clock \n",
        "ln -sf /usr/share/zoneinfo/Europe/London /etc/localtime\n",

        "echo [UserData] Updating Packages\n",
        "yum update -y\n",

        # Update bashrc with exports
        user_data_bashrc,
        ". /home/ec2-user/.bashrc\n",

        # Create a web user and permissions for our web directory
        "mkdir -p /var/www/html/example-app\n",

        # Add a www group and add ec2-user to that group.
        "groupadd www\n",
        "usermod -a -G www ec2-user\n",
        "chown -R ec2-user:www /var/www\n",
        "chmod 2775 /var/www\n",
        "find /var/www -type d -exec chmod 2775 {} +\n",
        "find /var/www -type f -exec chmod 0664 {} +\n",

        # Add our server app
        user_data_server_app,

        # Note: We cannot run node as a regular user (ec2-user) on ports < 1000
        # Install Node.js - v6.x + npm - https://nodejs.org/en/download/package-manager/
        "curl --silent --location https://rpm.nodesource.com/setup_6.x | sudo bash -\n",
        "yum -y install nodejs\n",

        # Install nginx + start on boot
        "yum -y install nginx\n",
        "chkconfig nginx on\n",
        # WebRoot: /usr/share/nginx/html
        # Config:  /etc/nginx/nginx.conf

        # Change our WebRoot directory
        # from: /usr/share/nginx/html
        # to:   /var/www/html
        user_data_nginx_index,
        user_data_nginx_conf,
        "service nginx restart\n",
        # "sed -i 's/\/usr\/share\/nginx\/html;/\/var\/www\/html;/g' /etc/nginx/nginx.conf\n",

        # https://github.com/Unitech/pm2
        # We must export these vars else .pm2 will be installed to /root/.pm2 or /etc/.pm2
        user_data_pm2_config,
        "npm install pm2 -g\n",
        "pm2 startup -u ec2-user --hp /home/ec2-user\n",
        "sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemv -u ec2-user --hp /home/ec2-user\n"

        # https://github.com/Unitech/pm2#load-balancing--zero-second-downtime-reload
        "pm2 start /var/www/html/example-app/pm2config.json -u ec2-user --hp /home/ec2-user\n",
        "pm2 save\n",

        # pm2 fails to write to our log files as it was created by root, so own it
        "chown -R ec2-user:ec2-user /home/ec2-user/.pm2\n",

        "\n",
        ]
     ))
))

#
# Associate our ElasticIP (from Domain-Stack) to our instance
#
eip_association = template.add_resource(ec2.EIPAssociation(
    "EIPEC2Association",
    EIP=ImportValue('ElasticIP'),
    InstanceId=Ref(ec2_instance),
))


#
# ┌───────────────────────────────────────────────────────────────────────────┐
# │                                 JSON DUMP                                 │
# └───────────────────────────────────────────────────────────────────────────┘
#
with open(os.path.realpath(__file__)[:-2] + 'json', 'w') as the_file:
    print(template.to_json(), file=the_file)
    print('Generated: ' + __file__)
{
    "Description": "Node.js, Nginx stack w/PM | anil.io",
    "Parameters": {
        "KeyPairName": {
            "Default": "nld_aws_london",
            "Description": "Name of an existing EC2 KeyPair to enable SSH access to the instance",
            "Type": "AWS::EC2::KeyPair::KeyName"
        },
        "LinuxAmi": {
            "Default": "ami-489f8e2c",
            "Description": "The AMI ID for our Linux Web server instance. (default: Amazon Linux AMI 2017 - ami-489f8e2c)",
            "Type": "AWS::EC2::Image::Id"
        }
    },
    "Resources": {
        "EIPEC2Association": {
            "Properties": {
                "EIP": {
                    "Fn::ImportValue": "ElasticIP"
                },
                "InstanceId": {
                    "Ref": "Ec2Instance"
                }
            },
            "Type": "AWS::EC2::EIPAssociation"
        },
        "Ec2Instance": {
            "Properties": {
                "ImageId": {
                    "Ref": "LinuxAmi"
                },
                "InstanceType": "t2.small",
                "KeyName": {
                    "Ref": "KeyPairName"
                },
                "SecurityGroupIds": [
                    {
                        "Ref": "InstanceSecurityGroup"
                    }
                ],
                "SubnetId": {
                    "Fn::ImportValue": "Subnet1"
                },
                "Tags": [
                    {
                        "Key": "Name",
                        "Value": {
                            "Fn::Join": [
                                "",
                                [
                                    "Nginx, Node & PM",
                                    " - ",
                                    {
                                        "Ref": "AWS::StackName"
                                    }
                                ]
                            ]
                        }
                    }
                ],
                "UserData": {
                    "Fn::Base64": {
                        "Fn::Join": [
                            "",
                            [
                                "#!/bin/bash\n",
                                "sed -i '/ZONE=\"UTC\"/c\\ZONE=\"Europe/London\"' /etc/sysconfig/clock \n",
                                "ln -sf /usr/share/zoneinfo/Europe/London /etc/localtime\n",
                                "echo [UserData] Updating Packages\n",
                                "yum update -y\n",
                                " cat << EOF >> /home/ec2-user/.bashrc\n\n# User\nexport PATH=./bin:$PATH\nalias l='ls -alh'\n\n# .pm2\nexport USER=ec2-user\nexport HOME=/home/ec2-user\nexport PM2_HOME=/home/ec2-user/.pm2\nEOF\n",
                                ". /home/ec2-user/.bashrc\n",
                                "mkdir -p /var/www/html/example-app\n",
                                "groupadd www\n",
                                "usermod -a -G www ec2-user\n",
                                "chown -R ec2-user:www /var/www\n",
                                "chmod 2775 /var/www\n",
                                "find /var/www -type d -exec chmod 2775 {} +\n",
                                "find /var/www -type f -exec chmod 0664 {} +\n",
                                "cat << EOF >> /var/www/html/example-app/server.js\nvar http = require('http');\nvar port = 9500; // Must change IPTables also\nvar server = http.createServer(function(request, response) {\n    response.setHeader('Content-Type', 'text/html');\n    response.end('<h1>Node.js server is running!</h1>');\n});\nserver.listen(port, function() {\n    console.log('Node.js server listening on port: ' + port);\n});\n\nEOF\n",
                                "curl --silent --location https://rpm.nodesource.com/setup_6.x | sudo bash -\n",
                                "yum -y install nodejs\n",
                                "yum -y install nginx\n",
                                "chkconfig nginx on\n",
                                "cat << EOF >> /var/www/html/index.html\n<h1>Nginx server, running at <code>/var/www/html</code></h1>\nEOF\n",
                                "cat << EOF > /etc/nginx/nginx.conf\n# For more information on configuration, see:\n#   * Official English Documentation: http://nginx.org/en/docs/\n#   * Official Russian Documentation: http://nginx.org/ru/docs/\n\nuser nginx;\nworker_processes auto;\nerror_log /var/log/nginx/error.log;\npid /var/run/nginx.pid;\n\n# Load dynamic modules. See /usr/share/doc/nginx/README.dynamic.\ninclude /usr/share/nginx/modules/*.conf;\n\nevents {\n    worker_connections 1024;\n}\n\nhttp {\n    log_format  main  '$remote_addr - $remote_user [$time_local] \"$request\" '\n                      '$status $body_bytes_sent \"$http_referer\" '\n                      '\"$http_user_agent\" \"$http_x_forwarded_for\"';\n\n    access_log  /var/log/nginx/access.log  main;\n\n    sendfile            on;\n    tcp_nopush          on;\n    tcp_nodelay         on;\n    keepalive_timeout   65;\n    types_hash_max_size 2048;\n\n    include             /etc/nginx/mime.types;\n    default_type        application/octet-stream;\n\n    # Load modular configuration files from the /etc/nginx/conf.d directory.\n    # See http://nginx.org/en/docs/ngx_core_module.html#include\n    # for more information.\n    include /etc/nginx/conf.d/*.conf;\n\n    index   index.html index.htm;\n\n    upstream node_app {\n        server 127.0.0.1:9500;\n    }\n\n    server {\n        listen       80 default_server;\n        listen       [::]:80 default_server;\n        server_name  localhost;\n        root         /var/www/html;\n\n        # Load configuration files for the default server block.\n        include /etc/nginx/default.d/*.conf;\n\n        location / {\n            proxy_pass   http://node_app;\n        }\n\n        # redirect server error pages to the static page /40x.html\n        #\n        error_page 404 /404.html;\n            location = /40x.html {\n        }\n\n        # redirect server error pages to the static page /50x.html\n        #\n        error_page 500 502 503 504 /50x.html;\n            location = /50x.html {\n        }\n\n        # proxy the PHP scripts to Apache listening on 127.0.0.1:80\n        #\n        #location ~ \\.php$ {\n        #    proxy_pass   http://127.0.0.1;\n        #}\n\n        # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000\n        #\n        #location ~ \\.php$ {\n        #    root           html;\n        #    fastcgi_pass   127.0.0.1:9000;\n        #    fastcgi_index  index.php;\n        #    fastcgi_param  SCRIPT_FILENAME  /scripts$fastcgi_script_name;\n        #    include        fastcgi_params;\n        #}\n\n        # deny access to .htaccess files, if Apache's document root\n        # concurs with nginx's one\n        #\n        #location ~ /\\.ht {\n        #    deny  all;\n        #}\n    }\n\n# Settings for a TLS enabled server.\n#\n#    server {\n#        listen       443 ssl http2 default_server;\n#        listen       [::]:443 ssl http2 default_server;\n#        server_name  _;\n#        root         /var/www/html;\n#\n#        ssl_certificate \"/etc/pki/nginx/server.crt\";\n#        ssl_certificate_key \"/etc/pki/nginx/private/server.key\";\n#        # It is *strongly* recommended to generate unique DH parameters\n#        # Generate them with: openssl dhparam -out /etc/pki/nginx/dhparams.pem 2048\n#        #ssl_dhparam \"/etc/pki/nginx/dhparams.pem\";\n#        ssl_session_cache shared:SSL:1m;\n#        ssl_session_timeout  10m;\n#        ssl_protocols TLSv1 TLSv1.1 TLSv1.2;\n#        ssl_ciphers HIGH:SEED:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!RSAPSK:!aDH:!aECDH:!EDH-DSS-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA:!SRP;\n#        ssl_prefer_server_ciphers on;\n#\n#        # Load configuration files for the default server block.\n#        include /etc/nginx/default.d/*.conf;\n#\n#        location / {\n#        }\n#\n#        error_page 404 /404.html;\n#            location = /40x.html {\n#        }\n#\n#        error_page 500 502 503 504 /50x.html;\n#            location = /50x.html {\n#        }\n#    }\n\n}\n\nEOF\n",
                                "service nginx restart\n",
                                "cat << EOF >> /var/www/html/example-app/pm2config.json\n{\n\t\"apps\": [{\n\t\t\"name\"\t: \"example-app\",\n\t\t\"script\": \"/var/www/html/example-app/server.js\",\n\t\t\"watch\":  [\"views\", \"public\", \"routes\"],\n\t\t\"instances\" : \"max\",\n    \t\"exec_mode\" : \"cluster\",\n\t\t\"env\": {\n\t\t\t\"NODE_ENV\": \"development\"\n\t\t},\n\t\t\"env_production\": {\n\t\t\t\"NODE_ENV\": \"production\"\n\t\t},\n\t\t\"log_date_format\":  \"MM-DD-YYYY HH:mm Z\",\n\t\t\"error_file\":  \"/home/ec2-user/.pm2/logs/myapp_err.log\",\n\t\t\"out_file\":  \"/home/ec2-user/.pm2/logs/myapp_out.log\"\n\t}]\n}\n\nEOF\n",
                                "npm install pm2 -g\n",
                                "pm2 startup -u ec2-user --hp /home/ec2-user\n",
                                "sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemv -u ec2-user --hp /home/ec2-user\npm2 start /var/www/html/example-app/pm2config.json -u ec2-user --hp /home/ec2-user\n",
                                "pm2 save\n",
                                "chown -R ec2-user:ec2-user /home/ec2-user/.pm2\n",
                                "\n"
                            ]
                        ]
                    }
                }
            },
            "Type": "AWS::EC2::Instance"
        },
        "InstanceSecurityGroup": {
            "Properties": {
                "GroupDescription": "Enable EC2 HTTP, HTTPS",
                "SecurityGroupIngress": [
                    {
                        "CidrIp": "0.0.0.0/0",
                        "FromPort": "22",
                        "IpProtocol": "tcp",
                        "ToPort": "22"
                    },
                    {
                        "CidrIp": "0.0.0.0/0",
                        "FromPort": "80",
                        "IpProtocol": "tcp",
                        "ToPort": "80"
                    },
                    {
                        "CidrIp": "0.0.0.0/0",
                        "FromPort": "443",
                        "IpProtocol": "tcp",
                        "ToPort": "443"
                    }
                ],
                "Tags": [
                    {
                        "Key": "Name",
                        "Value": {
                            "Fn::Join": [
                                "",
                                [
                                    "Nginx, Node & PM",
                                    " - ",
                                    {
                                        "Ref": "AWS::StackName"
                                    }
                                ]
                            ]
                        }
                    }
                ],
                "VpcId": {
                    "Fn::ImportValue": "VPC"
                }
            },
            "Type": "AWS::EC2::SecurityGroup"
        }
    }
}