├── .gitignore ├── LICENSE ├── README.md ├── ansible.cfg ├── host_vars └── mylittleapp.org.template ├── hosts.template ├── nginx.conf.j2 └── site.yml /.gitignore: -------------------------------------------------------------------------------- 1 | hosts 2 | host_vars/* 3 | !host_vars/mylittleapp.org.template 4 | authorized_keys/* 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Markus Amalthea Magnuson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | My Little App 2 | ============= 3 | 4 | #### Easy setup of your own Heroku clone with Ansible and Dokku 5 | 6 | --- 7 | 8 | This is an [Ansible](https://github.com/ansible/ansible) playbook that from scratch will automatically turn a pristine server into your own Heroku clone. All you need is the server, a domain name, and a DNS service supporting alias records. Everything is powered by [Dokku](https://github.com/progrium/dokku), which itself runs on [Docker](https://www.docker.io/). 9 | 10 | 11 | Setup 12 | ----- 13 | 14 | ([See bottom section if you are feeling adventurous.](#my-little-crazy-one-shot-auto-setup)) 15 | 16 | Just follow these instructions, and you will soon be up and running: 17 | 18 | 1. **Register a domain name** where your applications will live, for example `mylittleapp.org`. Each application will run on a subdomain of this host, e.g. `helloworld.mylittleapp.org`. 19 | 20 | 2. **Set up a new server** that you can access by SSH key only. This playbook has been tested on a new Ubuntu 13.04 droplet on [DigitalOcean](https://www.digitalocean.com/), but a variety of other setups should work as well. 21 | 22 | 3. **Set up two DNS entries** for the new domain name, both pointing at the new server's IP address: 23 | * One `A` record for `mylittleapp.org` 24 | * One `A` record for `*.mylittleapp.org` 25 | 26 | Your DNS provider must support alias A records of some kind. The above example works on [Amazon Route 53](http://aws.amazon.com/route53/), but something like [DNSimple](https://dnsimple.com/) should work as well. 27 | 28 | 4. Make sure your new domain name points at the name servers of your DNS service. 29 | 30 | 5. [Install Ansible](http://docs.ansible.com/intro_installation.html) and its dependencies on your local machine. 31 | 32 | 6. Clone this repository to your local machine: `git clone https://github.com/alimony/mylittleapp.git` 33 | 34 | 7. Copy the file `hosts.template` to `hosts` and change "mylittleapp.org" to your own domain name. 35 | 36 | 8. Copy your public SSH key to the `authorized_keys` directory (e.g. `cp ~/.ssh/id_dsa.pub authorized_keys/mykey.pub`) 37 | 38 | 9. **Everything should be ready now!** Sit back, relax, and run: `ansible-playbook site.yml -v` 39 | 40 | 41 | Deploying applications 42 | ---------------------- 43 | 44 | To deploy an application to your new server, you just have to add a remote to an existing git repository and push to it. Starting with an empty directory, it can look something like this: 45 | 46 | ```bash 47 | git init 48 | echo '' > index.php 49 | git add . 50 | git commit -m 'Initial commit.' 51 | git remote add deploy dokku@mylittleapp.org:helloworld 52 | git push deploy master 53 | ``` 54 | 55 | There will now be application deployment magic happening, much like you're used to from Heroku. The application type is auto-detected and everything needed to run it will be installed (inside a Docker container) and launched. To visit your new application, just go to `.mylittleapp.org`. (In the example above that would be `helloworld.mylittleapp.org`.) 56 | 57 | All subdomains that are not pointing at an application will redirect to the main hostname. If you want to run an application at the main hostname, create one with just your domain name as its name: 58 | 59 | ```bash 60 | git init 61 | mkdir www 62 | echo 'My little home' > www/index.html 63 | touch .nginx 64 | git add . 65 | git commit -m 'Initial commit.' 66 | git remote add deploy dokku@mylittleapp.org:mylittleapp.org 67 | git push deploy master 68 | ``` 69 | 70 | Touching the `.nginx` file is how the [nginx buildpack](https://github.com/rhy-jot/buildpack-nginx) detects it's a static application. All the actual files for the site should live in the `www` directory. 71 | 72 | #### Important 73 | 74 | You should really set up an application at the main hostname, or it will be in a redirection loop. 75 | 76 | 77 | Additional notes 78 | ---------------- 79 | 80 | * To give more users access to the setup, just add their public SSH to key the `authorized_keys` directory and run the playbook again. 81 | * By default, Dokku will be fetched from the `HEAD` of its `master` branch. If you want to use another branch, or a specific tag or commit, just change the `dokku_version=HEAD` part in `hosts`, for example `dokku_version=v0.2.2`. 82 | * For maximum encapsulation, you might want to install and run Ansible itself from a [virtual environment](http://virtualenvwrapper.readthedocs.org/). Just `cd` into your Ansible directory and run `pip install -e .` to install all its dependencies. 83 | 84 | 85 | Improvements 86 | ------------ 87 | The purpose of this deliberately simple setup is to get you up and running as easy as possible. Only what should be useful to most people is included, which will continue to be the criteria for any changes. With that said, there is of course room for much improvement. If you have ideas, please [open an issue](https://github.com/alimony/mylittleapp/issues) about it, or open a pull request directly if you have code to contribute. 88 | 89 | 90 | Disclaimer 91 | ---------- 92 | This is my first public Ansible playbook ever. Much of it is probably written in a sub-optimal way, since I have yet to learn all the ins and outs of how to organize playbooks, roles, etc. If you have useful input, let me know. I will happily learn and adjust. I can't guarantee that this playbook will not hurt your server, so please only run it on a fresh one, or one that you do not care for :) 93 | 94 | 95 | Credits 96 | ------- 97 | My Little App is merely a piece of glue. The heavy lifting is carried out by [Ansible](http://www.ansible.com/home), [Dokku](https://github.com/progrium/dokku) and [Docker](https://www.docker.io/), as well as all other parts of great open source software running today's internet infrastructure. If you like this project, please check out the others, and try to help out where you can. Thank you! 98 | 99 | --- 100 | 101 | ###### My Little Crazy One Shot Auto Setup™ 102 | 103 | If you are feeling adventurous, there is experimental work on creating a server and setting up DNS records as part of the playbook. This means you can go from nothing to a fully working Heroku clone with one single command. Currently, only DigitalOcean and Amazon Route 53 is supported. To try this out, make sure you meet these requirements: 104 | 105 | * You have a new domain name with its name servers pointed at a hosted zone in Amazon Route 53. 106 | * You have your API credentials for both DigitalOcean and Amazon Web Services handy. 107 | * For DigitalOcean you will need the python package `dopy` and for Route 53 you need `boto`. 108 | 109 | This is how to proceed: 110 | 111 | 1. Clone this repository to your local machine: `git clone https://github.com/alimony/mylittleapp.git` 112 | 2. Copy `mylittleapp.org.template` to `mylittleapp.org` in `host_vars` and edit as needed, uncommenting the `server_provider` and `dns_provider` lines. Be sure to fill in all `do_` and `aws_` setting values. 113 | 3. Copy `hosts.template` to `hosts` and change it to your own domain name. 114 | 4. Copy your public SSH key to the `authorized_keys` directory: `cp ~/.ssh/id_dsa.pub authorized_keys/mykey.pub` 115 | 5. Run the playbook and enjoy the ride: `ansible-playbook site.yml -v` 116 | 117 | Hopefully, the end result will be the same as before, with with far less work. Note that this feature is highly experimental and needs a lot more testing to be considered stable. 118 | -------------------------------------------------------------------------------- /ansible.cfg: -------------------------------------------------------------------------------- 1 | [defaults] 2 | hostfile = hosts 3 | -------------------------------------------------------------------------------- /host_vars/mylittleapp.org.template: -------------------------------------------------------------------------------- 1 | --- 2 | dokku_git_repo: https://github.com/progrium/dokku.git 3 | dokku_version: HEAD 4 | 5 | 6 | ### DigitalOcean 7 | 8 | # Related settings are prefixed with 'do_' below. 9 | 10 | # WARNING: This is an experimental feature! 11 | # Uncomment the line below to enable automatic droplet/server creation. 12 | #server_provider: digital_ocean 13 | 14 | # API credentials are found/created at: 15 | # https://cloud.digitalocean.com/api_access 16 | do_client_id: abc123 17 | do_api_key: abc123 18 | 19 | # Numerical. Currently, the possible region values are: 20 | # 1 (New York 1) 21 | # 2 (Amsterdam 1) 22 | # 3 (San Francisco 1) 23 | # 4 (New York 2) 24 | # 5 (Amsterdam 2) 25 | # 6 (Singapore 1) 26 | do_region_id: 2 27 | 28 | # Numerical. Both private and public images are available, which are too many to 29 | # list here. For a full list of available size, visit: 30 | # https://api.digitalocean.com/images/?client_id=[client_id]&api_key=[api_key] 31 | # Ubuntu 13.04 x64 32 | do_image_id: 350076 33 | 34 | # Numerical. Currently, the possible image size values are: 35 | # 66 (512 MB memory, 20 GB disk) 36 | # 63 (1 GB memory, 30 GB disk) 37 | # 62 (2 GB memory, 40 GB disk) 38 | # 64 (4 GB memory, 60 GB disk) 39 | # 65 (8 GB memory, 80 GB disk) 40 | # 61 (16 GB memory, 160 GB disk) 41 | # 60 (32 GB memory, 320 GB disk) 42 | # 70 (48 GB memory, 480 GB disk) 43 | # 69 (64 GB memory, 640 GB disk) 44 | # For full size and pricing info, visit: 45 | # https://www.digitalocean.com/pricing/ 46 | do_size_id: 66 47 | 48 | 49 | ### Amazon Route 53 50 | 51 | # Related settings are prefixed with 'aws_' below. 52 | 53 | # WARNING: This is an experimental feature! 54 | # Uncomment the line below to enable automatic DNS record creation. Make sure 55 | # you have already created a DNS zone for your domain name, as this is currently 56 | # not happening automatically. 57 | #dns_provider: route53 58 | 59 | # API credentials are found/created at: 60 | # https://console.aws.amazon.com/iam/home?#security_credential 61 | aws_access_key: abc123 62 | aws_secret_key: abc123 63 | -------------------------------------------------------------------------------- /hosts.template: -------------------------------------------------------------------------------- 1 | mylittleapp.org 2 | -------------------------------------------------------------------------------- /nginx.conf.j2: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80 default_server; 3 | listen [::]:80 default_server ipv6only=on; 4 | 5 | # Redirect to main host. 6 | return 301 $scheme://{{ ansible_fqdn }}; 7 | 8 | # But keep some root settings, just to be sure. 9 | root /usr/share/nginx/html; 10 | index index.html index.htm; 11 | 12 | # Make site accessible from http://localhost/ 13 | server_name localhost; 14 | 15 | location / { 16 | # First attempt to serve request as file, then 17 | # as directory, then fall back to displaying a 404. 18 | try_files $uri $uri/ =404; 19 | # Uncomment to enable naxsi on this location 20 | # include /etc/nginx/naxsi.rules 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /site.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: all 3 | vars: 4 | ansible_python_interpreter: "/usr/bin/env python" 5 | gather_facts: False 6 | connection: local 7 | tasks: 8 | 9 | # If the variable 'server_provider' is specified in the host variables, we 10 | # will create a new server as first step in the deploy process. But any SSH 11 | # keys will have to be added before that can happen, which is why we 12 | # use a local connection for these tasks; there is no host yet. 13 | 14 | - name: Make sure all public SSH keys are on DigitalOcean. 15 | digital_ocean: command=ssh 16 | ssh_pub_key="{{ lookup('file', item) }}" 17 | name={{ item | basename }} 18 | state=present 19 | client_id={{ do_client_id }} 20 | api_key={{ do_api_key }} 21 | with_fileglob: 22 | - authorized_keys/* 23 | register: result 24 | when: server_provider is defined and server_provider == 'digital_ocean' 25 | 26 | # Get or create a droplet for this hostname, which will have all available 27 | # SSH keys added to it by default. 28 | - name: Create a DigitalOcean droplet for this hostname. 29 | digital_ocean: command=droplet 30 | state=present 31 | ssh_key_ids={{ result['results'] | map(attribute='ssh_key') | map(attribute='id') | join(',') }} 32 | name={{ inventory_hostname }} 33 | region_id={{ do_region_id }} 34 | image_id={{ do_image_id }} 35 | size_id={{ do_size_id }} 36 | state=active 37 | unique_name=yes 38 | client_id={{ do_client_id }} 39 | api_key={{ do_api_key }} 40 | register: result 41 | when: server_provider is defined and server_provider == 'digital_ocean' 42 | 43 | # If a new server was created, it will have a new SSH signature. Since ip 44 | # addresses are often reused on DigitalOcean, you will probably get one that 45 | # you have used before. This will cause alarms to go of in SSH, and prevent 46 | # any commands to run. To remedy, remove the relevant line from known_hosts 47 | # before continuing. 48 | # TODO: Do not hardcode the path to the .ssh directory. 49 | - name: Make sure the new server's ip address is not already in known_hosts. 50 | lineinfile: dest=~/.ssh/known_hosts 51 | regexp="^{{ result['droplet']['ip_address'] }}" 52 | state=absent 53 | when: server_provider is defined and server_provider == 'digital_ocean' and result|changed 54 | 55 | - name: Make sure the new server's host name is not already in known_hosts. 56 | lineinfile: dest=~/.ssh/known_hosts 57 | regexp="^{{ inventory_hostname }}" 58 | state=absent 59 | when: server_provider is defined and server_provider == 'digital_ocean' and result|changed 60 | 61 | - name: Add the new server address to a group for later reference. 62 | add_host: name={{ result['droplet']['ip_address'] }} 63 | groups=dokku 64 | when: server_provider is defined and server_provider == 'digital_ocean' 65 | 66 | # This will set up some "fake" variables by exposing those available to the 67 | # name based host to the ip based host. They will later be references using 68 | # a kind of weird hack. 69 | # TODO: Reorganize the whole project to avoid such folly. 70 | 71 | - set_fact: ip_address={{ result['droplet']['ip_address'] }} 72 | when: server_provider is defined and server_provider == 'digital_ocean' 73 | 74 | - set_fact: dokku_git_repo={{ dokku_git_repo }} 75 | when: server_provider is defined and server_provider == 'digital_ocean' 76 | 77 | - set_fact: dokku_version={{ dokku_version }} 78 | when: server_provider is defined and server_provider == 'digital_ocean' 79 | 80 | # If server_provider is not defined for this host, we will assume that the 81 | # hostname itself is set up and reachable through SSH. 82 | - name: Add hostname to group for later reference. 83 | add_host: name={{ inventory_hostname }} 84 | groups=dokku 85 | when: server_provider is not defined 86 | 87 | # If the variable 'dns_provider' is specified in the host variables, we 88 | # will create DNS entries for the current hostname, if not already present. 89 | 90 | - name: Check if DNS records already exist. 91 | route53: command=get 92 | zone={{ inventory_hostname }} 93 | record={{ inventory_hostname }} 94 | type=A 95 | aws_access_key={{ aws_access_key }} 96 | aws_secret_key={{ aws_secret_key }} 97 | register: result 98 | when: dns_provider is defined and dns_provider == 'route53' 99 | 100 | - name: Add A record to DNS for this hostname. 101 | route53: command=create 102 | zone={{ inventory_hostname }} 103 | record={{ inventory_hostname }} 104 | type=A 105 | value={{ ip_address }} 106 | ttl=300 107 | overwrite=yes 108 | aws_access_key={{ aws_access_key }} 109 | aws_secret_key={{ aws_secret_key }} 110 | when: dns_provider is defined and dns_provider == 'route53' and result.set | length == 0 111 | 112 | - name: Add A wildcard DNS record for this hostname. 113 | route53: command=create 114 | zone={{ inventory_hostname }} 115 | record="*.{{ inventory_hostname }}" 116 | type=A 117 | value={{ ip_address }} 118 | ttl=300 119 | overwrite=yes 120 | aws_access_key={{ aws_access_key }} 121 | aws_secret_key={{ aws_secret_key }} 122 | when: dns_provider is defined and dns_provider == 'route53' and result.set | length == 0 123 | 124 | - hosts: dokku 125 | # TODO: Do not hardcode the remote username. 126 | remote_user: root 127 | tasks: 128 | 129 | - set_fact: dokku_git_repo={{ item.value.dokku_git_repo }} 130 | with_dict: hostvars 131 | when: item.value.ip_address is defined and inventory_hostname == item.value.ip_address 132 | 133 | - set_fact: dokku_version={{ item.value.dokku_version }} 134 | with_dict: hostvars 135 | when: item.value.ip_address is defined and inventory_hostname == item.value.ip_address 136 | 137 | - name: Ensure LC_ALL is set to fix annoying local errors on pristine server 138 | lineinfile: dest=/etc/environment 139 | line="LC_ALL=en_US.UTF-8" 140 | state=present 141 | 142 | - name: Ensure LANG is set to fix annoying local errors on pristine server 143 | lineinfile: dest=/etc/environment 144 | line="LANG=en_US.UTF-8" 145 | state=present 146 | 147 | - name: Set up authorized_keys for root user 148 | authorized_key: user=root 149 | key="{{ lookup('file', item) }}" 150 | state=present 151 | with_fileglob: 152 | - authorized_keys/* 153 | 154 | - name: Install packages 155 | apt: pkg={{ item }} 156 | state=latest 157 | with_items: 158 | - aufs-tools 159 | - build-essential 160 | - git 161 | - software-properties-common 162 | 163 | - name: Update all package definitions and upgrade all installed packages 164 | apt: upgrade=dist 165 | update_cache=yes 166 | 167 | - name: Refresh dokku repository, cloning it if non-existent 168 | git: repo={{ dokku_git_repo }} 169 | dest=/root/dokku 170 | version={{ dokku_version }} 171 | 172 | - name: Install dokku from the latest repo version 173 | command: sudo /usr/bin/make install 174 | chdir=/root/dokku 175 | 176 | - name: Set up authorized_keys for dokku user 177 | # TODO: The last touch is a hack, 'creates' should do it for us... but that doesn't work for some reason. 178 | shell: echo {{ lookup('file', item) }} | sudo sshcommand acl-add dokku progrium && touch /home/dokku/.acl-add-done 179 | creates=/home/dokku/.acl-add-done 180 | with_fileglob: 181 | - authorized_keys/* 182 | 183 | - name: Make sure the VHOST file is set up correctly. 184 | copy: content={{ ansible_fqdn }} 185 | dest=/home/dokku/VHOST 186 | force=yes 187 | 188 | - name: Make sure the HOSTNAME file is set up correctly. 189 | copy: content={{ ansible_fqdn }} 190 | dest=/home/dokku/HOSTNAME 191 | force=yes 192 | 193 | - name: Copy custom default nginx configuration to sites 194 | template: src=nginx.conf.j2 195 | dest=/etc/nginx/sites-available/{{ ansible_fqdn }} 196 | force=yes 197 | 198 | - name: Disable the existing default nginx site 199 | file: path=/etc/nginx/sites-enabled/default 200 | state=absent 201 | 202 | - name: Enable the custom default nginx site 203 | file: src=/etc/nginx/sites-available/{{ ansible_fqdn }} 204 | dest=/etc/nginx/sites-enabled/{{ ansible_fqdn }} 205 | state=link 206 | force=yes 207 | 208 | - name: Reload nginx configuration 209 | service: name=nginx 210 | state=reloaded 211 | --------------------------------------------------------------------------------