Getting started with Puppet

There are many Puppet tutorials out there, but I don't think any of then adequately explain why Puppet is a good choice, start from a sufficiently basic level, cover enough features to be actually useful, and have an end goal in mind to focus them. In this tutorial, I'll be working towards creating a Puppet manifest for a server to run a Django project with a Postgres database behind nginx. I'll be assuming you already have a Django app to deploy, and are comfortable with the command line and basic server administration.

Why Puppet?

Configuration management tools like Puppet and Chef are often compared to remote deployment tools like Fabric and Capistrano. Fabric is a great tool for automating deployments (in fact we'll be using it to automate Puppet), but it's not the right tool for server configuration. Some of Puppet's advantages over Fabric are:

  • Repeatable: If you apply a Puppet manifest to the same server twice, Puppet will do nothing the second time. If you make a small change, Puppet will make only that change. With Fabric, the entire script is executed each time you run it, whether you make many changes, only a few changes, or no changes at all.
  • Composable: Puppet modules can be combined without fear that they will overwrite each other's changes: in the rare case that they conflict Puppet will raise an error before changing anything. The result of running two Fabric configuration scripts after one another can be unpredictable.
  • Reversible: Many changes Puppet makes to a system can be reversed simply by changing a present attribute to absent in the manifest. With Fabric, an entirely separate script is needed to reverse changes.
  • Dependency management: Puppet modules can declare other modules on which they depend, which Puppet will automatically process first. For instance, including a django module can automatically install and configure the python and nginx modules. With Fabric, either dependencies need to be managed manually, or each script has to configure all of its dependencies itself, which can lead to conflicts if multiple scripts try to configure the same dependency slightly differently.

I won't compare Puppet to Chef or other configuration management systems: like Python vs Ruby, they are very similar tools that solve the same problem, and the choice is really down to personal preference.

Getting started

Most Puppet tutorials start off with instructions for configuring the Puppetmaster: a server dedicated to just hosting the Puppet manifests. But for learning Puppet, and even for simple deployments of one or just a few servers, you really don't need a Puppetmaster. Instead, we'll be applying Puppet manifests directly to servers from the command line.

The best way to start learning Puppet is using a local virtual machine. Local shared folders allow you to quickly apply changes to the machine without fiddling with rsync or a version control system, network latency is completely eliminated when developing locally, and VM snapshots allow you to easily revert to a previous state if something goes seriously wrong when applying a manifest.

Setting up the VM

I'll be using Vagrant as the VM environment. Vagrant is great for this purpose since it manages things like shared folders and port forwarding for you. Start by creating a directory called puppet somewhere on your machine. Create a file called Vagrantfile inside it, and initialize it with the following:

Vagrant::Config.run do |config|
    config.vm.box = "precise32"
    config.vm.box_url = "http://files.vagrantup.com/precise32.box"
    config.vm.forward_port 80, 8080
end

Open a terminal in the directory, and run vagrant up. This will download an Ubuntu 12.04 image from vagrantup.com and install it into the directory. Once the command completes, the virtual machine will be running, even if you close the terminal. Run vagrant halt from the same directory to stop it, and vagrant destroy to reclaim the disk space it is consuming.

Vagrant unfortunately doesn't have snapshot functionality built in. You can install a plugin for it by running vagrant plugin install vagrant-vbox-snapshot.

Now, you can run vagrant snapshot take <name> to take a snapshot, vagrant snapshot list to list them, and vagrant snapshot go <name> to restore a snapshot.

Run vagrant ssh to open an SSH session into your virtual machine.

Installing Puppet

To install Puppet, let's execute:

sudo apt-get update
sudo apt-get install puppet

Now is a good time to make a snapshot of your VM's state. It's a good idea to periodically revert to this snapshot and ensure that the entire catalog applies cleanly from a fresh state.

Basic Puppet configuration

Let's go back to the puppet directory you created earlier. Set up the directory as follows:

+ files
  - (empty directory)
+ manifests
  - nodes.pp
  - site.pp
+ modules
  - (empty directory)

Initialize site.pp with the following:

import 'nodes.pp'

Initialize nodes.pp with the following:

node default {

}

We've now created the basic skeleton of a Puppet configuration. We can apply this manifest to the server with the following command:

sudo puppet apply /vagrant/manifests/site.pp

/vagrant is shared folder that Vagrant has automatically set up, pointing to the puppet directory.

Remember this command, as we will be using it very frequently to apply changes to the catalog. You may want to set up a shell alias for it.

Puppet should report a successful catalog run. Of course, our catalog doesn't actually do anything yet. Let's start adding some content to it.

Managing users

The Puppet user type manages users on a machine. Add the following inside the default node definition in nodes.pp:

user { '<your username>':
    ensure => 'present',
    groups => ['sudo'],
    home => '/home/<username>',
    managehome => true,
    password => '$6$lY2Gp3Cr$zNrUB7T3yibUF/gWn5cTQ0fNv7MUmx/DZuw3E7I..Vh9tITG28BtgvXJPU4Gm4Z/9oNvlbX24KzQ9Ib1QH1B9.',
    shell => '/bin/bash',
}

The password hash in the example is the hash for the password test.

Apply the configuration, and Puppet will report that it is creating your user. Try logging into your user to verify the set password.

If you apply the configuration again, you'll notice that Puppet doesn't output anything anymore. This is because it has detected that the configuration matches the existing machine state exactly, so it doesn't have to do anything.

Now change your user's password, and apply the configuration again. Puppet will output something like User[<your username>]/password: changed password, and your password will be changed back.

Managing groups: Dependency declaration

Let's also create a group ubuntu that our user will be a member of, that will own all our project files. We want this group to also be a regular user, so we can define it using the user type:

user { 'ubuntu':
  ensure           => 'present',
}

We can now add ubuntu to our user's group list:

groups => ['sudo', 'ubuntu'],

Before we apply this manifest, let's think about order in which our changes need to be processed. The ubuntu user needs to be created before our user's groups are updated, otherwise the group addition will result in an error. Puppet's ability to automatically detect dependencies like this is unfortunately very limited, so we must explicitly declare it. There are three ways we can do this.

The first is using the require attribute on our user. This special attribute is available on all Puppet types. The following can be added to our user declaration:

require => User['ubuntu']

In general, Puppet objects declared with the syntax <type name> { '<object name>': are referenced with the syntax <capitalized type name>['<object name>']. The syntax is somewhat arbitrary, but it's consistent.

The second way is to use the chaining operator ->. The chaining operator is used to declare a dependency between two object references. The following can be added to the manifest, outside of either user definition:

User['ubuntu'] -> User['<username>']

The final way also uses the chaining operator, but directly between the two object definitions. The declarations can be updated to look like:

user { '<username>':
    <attributes…>
} ->
user { 'ubuntu':
    <attributes…>
}

All three ways are equivalent; you can use whichever you prefer.

Using Puppet modules

We've been placing all our code into the root Puppet manifest nodes.pp. However, just like in other programming languages, putting all your code in a single file is a bad practice. Puppet modules allow us to organize our manifests into logical modules. We'll create a users module to hold our user configuration.

Create some subdirectories of modules:

+ modules
  + users
    + manifests
      - init.pp
    + files

Initialize init.pp with:

class users {

}

Now copy all of the code from node default to class users. To include the users class in the default node, use an include statement in nodes.pp:

node default {
    include users
}

To allow Puppet to find the code in your modules, you'll need to modify the command you use to apply the configuration:

sudo puppet apply --modulepath=/vagrant/modules /vagrant/manifests/site.pp

Note that an import statement to load the users module from nodes.pp is not required. Puppet will automatically parse all files in the module path and load all classes found into a global namespace.

Managing home directories

Everybody has personal configuration files they want installed on every machine they access. We can accomplish this using Puppet. Set up this directory structure, containing all the configuration files you want:

+ modules
  + users
    + files
      + <username>
        - .bashrc
        - .gitconfig
        - (any other files)

Add the following to the users class:

file { '/home/<username>':
    ensure => directory,
    owner => '<username>',
    group => '<username>',
    mode => 755,
    source => 'puppet:///modules/users/<username>',
    recurse => remote,
    require => User['<username>'],
}

The Puppet file type allows managing single files or full directories. We provide the owner, group, and octal mode the file should be kept at. The source attribute defines the location the file should be defined from. Note that the files path component is omitted - Puppet implicitly adds it. We use the require parameter from before to ensure the owner user exists before we create the home directory.

The recurse property defines how Puppet handles directories:

  • false, the default, simply sets the mode, owner, and group of the directory without touching any files inside.
  • true copies files from the source to the target directory, and applies the mode, owner, and group settings to all existing files inside the target directory.
  • remote copies files from the source to the target directory, but does not touch any existing files in the target directory. This is the behavior we want here.

Apply the manifest, and Puppet will copy all the files you put in modules/users/files/<username> to your home directory on the target machine.

Setting up SSH

Let's set up our first real service on our server. Create an ssh module:

+ modules
  + ssh
    + manifests
      - init.pp
    + files

Include the new module in nodes.pp:

node default {
    include users
    include ssh
}

Initialize init.pp:

class ssh {
    include ssh::install, ssh::config, ssh::service
}

class ssh::install {

}

class ssh::config {

}

class ssh::service {

}

Class["ssh::install"] -> Class["ssh::config"] -> Class["ssh::service"]

We're using a more complex module layout here. We've broken the ssh class down into the components ssh::install, ssh::config, and ssh::service. These classes provide a convenient way to group together the steps involved in each of these aspects of setting up a service. The last line uses the chaining operator to ensure the three components are executed in the correct order.

Start by defining ssh::install. The Puppet package type is used to declare packages that should be installed:

package { "ssh":
    ensure => present,
}

Apply the manifest, and Puppet will install the package.

In ssh::config, we'll manage the SSH server configuration file /etc/ssh/sshd_config. Add a Puppet file declaration:

file { "/etc/ssh/sshd_config":
    ensure => present,
    owner => 'root',
    group => 'root',
    mode => 600,
    source => "puppet:///modules/ssh/sshd_config",
}

Puppet will replace the sshd_config file on the system with the one found in the catalog at modules/ssh/files/sshd_config, so we need to create that file. But what should its contents be? There's many system defaults in that file that we shouldn't throw away. The best way to proceed is to copy the default /etc/ssh/sshd_config to modules/ssh/files/sshd_config, then make any modifications from there. This is a common pattern when settings up existing software with Puppet.

Let's make one modification to the sshd_config file in the Puppet catalog: setting PasswordAuthentication no.

Finally, let's set up the SSH service. Add the following to ssh::service:

service { "ssh":
    ensure => running,
    hasstatus => true,
    hasrestart => true,
    enable => true,
}

Puppet will now ensure the ssh service is running at all times.

There's one last step. When Puppet makes a change to sshd_config, the SSH server needs to be restarted for the change to take effect. Puppet supports this, but we need to explicitly tell it to do so using the notify property of the file type. Add the following attribute to the /etc/ssh/sshd_config file object:

notify => Class["ssh::service"],

Apply the manifest, and Puppet will update the sshd_config file with your change, and automatically restart the SSH server.

Now that we've disabled password authentication, we need to add authorized SSH keys for our user, or we won't be able to access the server. Puppet has the ssh_authorized_key type for this purpose. Add the following to the users module:

ssh_authorized_key { '<username>':
    ensure => 'present',
    user => '<username>',
    type => 'rsa',
    key => '<paste raw public key>',
}

Apply the manifest, and you should be able to access the machine through SSH using public-key authentication.

Installing Postgres

Every Django application needs a database, so let's install a local Postgres server. Make a new postgres module and populate its init.pp file with:

class postgres{
    include postgres::install, postgres::config, postgres::service
}

class postgres::install{
    package { 'postgresql':
        ensure => present
    }
}

class postgres::config{

}

class postgres::service{
    service {'postgresql':
        ensure => running,
        enable => true,
    }
}

Class["postgres::install"] -> Class["postgres::config"] -> Class["postgres::service"]

Don't forget to add include postgres to nodes.pp. The default configuration of Postgres is sufficient for the purposes of this tutorial, so we can leave postgres::config blank. To customize settings, you can use the same pattern from ssh::config.

Puppet doesn't have a built-in type to manage Postgres databases, and extending it to do so is significantly outside the scope of this tutorial. After applying the manifest, you'll need to create the user and database for Django manually:

sudo -u postgres createuser -P django
sudo -u postgres createdb -O django django

Setting up Git

Git will need to be installed on our machine for it to be able to clone and install our actual application. Let's create a git module.

Installing Git

Populate the Git module's init.pp with:

class git{
    include git::install
}

class git::install{
    package { 'git:':
        ensure => present
    }
}

When you add include git to nodes.pp and apply the manifest, Puppet will install Git on the machine.

Defining a custom type for cloning

Puppet doesn't have a native type for Git repositories. But because cloning a Git repository involves a simple shell command, we can use the Puppet exec type to define it. Add the following to the Git module's init.pp, outside of either of the classes:

define git::clone ( $path, $dir){
    exec { "clone-$name-$path":
        command => "/usr/bin/git clone git@github.com:$name $path/$dir",
        creates => "$path/$dir",
        require => [Class["git"], File[$path]],
    }
}

There's two new concepts here: the define statement and the exec type. The define statement is used to create a custom type that can be used elsewhere in the manifest, like a function in other languages. A statement of the form

git::clone {'<name>':
    path => '<path>',
    dir => '<dir>',
}

will be expanded by Puppet to the body of the define statement, with the values passed in the call substituted for the variables $name, $path, and $dir in the body. Note the implicit $name parameter, the first argument to all Puppet definitions.

The exec type allows you to configure Puppet to manage simple objects it doesn't have built-in types for. The most important attribute of this type is command. The command specified will be executed every time the manifest is executed. On its own, this subverts the declarative nature of Puppet: the command should only be run if actually necessary! So Puppet provides a number of additional attributes that allow you to make the execution conditional:

  • onlyif: Another shell command that will be executed first. The main command will be executed if and only if this command completes with a zero exit code.
  • unless: The opposite of onlyif. The main command will be executed if and only if this command completes with a nonzero exit code.
  • creates: A directory path that the command will create. The main command will be executed if and only if the directory does not exist.

The creates attribute fits the bill perfectly, since git clone will create the target directory. Once the directory is created, the command will not be run again.

Note the require attribute. It ensures that Git has been installed and the directory we are cloning to has been created before Puppet tries to run the clone command. These conditions may be obvious to us, but if we don't explicitly declare them, Puppet may apply our manifest in the wrong order.

Setting up keys

If the repository is public and you don't want to allow pushes from the machine, you can skip this step. Otherwise, we need to install a private key to our machine to allow it to access the repository on GitHub. Generate a new key pair and add it as a deploy key for the project you will be deploying. Add these files to the git module:

+ git
  + files
    - config
    - system_key
    - known_hosts

system_key is the private key you just generated.

config associates the system key with github.com:

Host github.com
    User git
    IdentityFile ~/.ssh/system_key

known_hosts should include the public key for github.com to avoid a prompt on the first connection. Since this may change, do a test connection from any computer, and copy the github.com entry into the file.

Now we need to tell Puppet where to put these files. Add the following to your manifest, perhaps in a new git::keys class that is included in the main git class:

file { "/home/<username>/.ssh":
    ensure => directory,
    owner => '<username>',
    group => '<username>',
    mode => 0600,
}

# Key for to be able to connect to GitHub
file { "/home/<username>/.ssh/system_key":
    ensure => present,
    source => "puppet:///modules/git/system_key",
    owner => '<username>',
    group => '<username>',
    mode => 0600,
    require => File['/home/<username>/.ssh'],
}

# Configure key to be automatically used for GitHub
file { "/home/<username>/.ssh/config":
    ensure => present,
    source => "puppet:///modules/git/config",
    owner => '<username>',
    group => '<username>',
    mode => 0600,
    require => File['/home/<username>/.ssh'],

}

# Add GitHub to known hosts to avoid prompt
file { "/home/<username>/.ssh/known_hosts":
    ensure => present,
    source => "puppet:///modules/git/known_hosts",
    owner => '<username>',
    group => '<username>',
    mode => 0600,
    require => File['/home/<username>/.ssh'],
}

Actually cloning files

Now that we've defined everything, let's test it out. Go ahead and call git::clone in nodes.pp:

file { '/usr/local/app':
    ensure => directory,
    owner => '<username>',
    group => '<username>',
    mode => 755,
}

git::clone { '<GitHub repository name>':
    path => '/usr/local/app',
    dir => 'django',
}

When you apply the manifest, /usr/local/app will be created, but the clone command will fail. Puppet's logs only include the exit code of the command, 128, which is not very helpful. A good debugging technique is to take a VM snapshot (so your experimentation doesn't mask the problem) and run the failing command yourself.

Copy the exact clone command from the error log into the command prompt and run it yourself. It should run successfully. So why can't Puppet run it? Remember that Puppet runs as root, so restore the VM snapshot and sudo the command to simulate that condition. GitHub will now reject the connection. Aha: we added the keys to our user account, not root! We could add the keys to root, but then all the cloned files would be owned by root, which isn't ideal. A better solution is to add a user parameter to the git::clone exec object:

user => '<username>',

Restore the snapshot again and apply the manifest. Puppet will now run the clone using your user account, and it should complete successfully.

Setting up Django

We're nearing the finish line: let's actually set up our Django project. Create a new django module.

Required packages

There's a few system packages we need to install first. Obviously we need python, as well as python-dev to allow the installation of C extension modules. python-virtualenv and python-pip are essential for dependency management. python-psycopg2 is needed to connect to Postgres. If your application requires PIL, I recommend using the python-imaging system package instead of installing it through pip, to avoid issues linking to the system JPEG and PNG libraries.

Use the package type to install these packages:

class django::install {
    package { [ "python", "python-dev", "python-virtualenv", "python-pip",
                "python-psycopg2", "python-imaging"]:
        ensure => present,
    }
}

Cloning the project

Since we've already defined the git::clone type, cloning the project is easy. Just move your test invocation in nodes.pp to a class in the django module:

class django::clone {
    git::clone { '<GitHub repository name>':
        path => '/usr/local/app',
        dir => 'django',
    }
}

Setting up the virtualenv

Good practice dictates that deployed Python applications should run out of a virtualenv. We can configure Puppet to manage the creation of the virtualenv with an exec type wrapped in a define to make it reusable:

 define django::virtualenv( $path ){
     exec { "create-ve-$path":
         command => "/usr/bin/virtualenv -q $name",
         cwd => $path,
         creates => "$path/$name",
         require => [Class["django::install"]],
     }
 }

We can now call this from a new class:

class django::environment {
    django::virtualenv{ 've':
        path => '/usr/local/app',
    }
}

Once the virtualenv is created, we need to use pip to install our project dependencies to it. However, since dependency installation needs to be rechecked every time the application is updated, this is not a good fit for Puppet. We could shoehorn it into another exec type, but it is better to give this responsibility to the tool that actually deploys new versions of your code to the server. We'll be configuring Fabric to do this later.

Setting up gunicorn

To serve the Django application, we'll be using the Gunicorn WSGI server. Gunicorn is pure Python, which makes it exceptionally simple to set up.

Installing Gunicorn is as easy as adding the latest version to your requirements.txt file:

gunicorn==0.14.6 # latest at time of writing

Test the server by running on the command line:

/usr/local/app/ve/bin/gunicorn_django --workers=1 --bind=0.0.0.0:8080 --pythonpath /usr/local/app/django settings

This command assumes your settings.py file is at the project root. If you have a more complex setup, simply change settings in the command to the module path of your production settings file.

Connect to port 8080 of your virtual machine to test the server.

Setting up supervisor

To start Gunicorn at system boot and ensure it is always running, we'll use the Supervisor process control system. There's an excellent contributed Puppet module that defines the supervisor::service type for us. Download the code from https://github.com/plathrop/puppet-module-supervisor and place it into a supervisor module on the same level as your own modules.

Now we can use the supervisor::service type:

class django::service {
    include supervisor
    supervisor::service { 'django':
        ensure => present,
        enable => true,
        command => '/usr/local/app/ve/bin/gunicorn_django --workers=9 --bind=127.0.0.1:10001 --pythonpath . settings',
        directory => '/usr/local/app/django',
        user => 'www-data',
    }
}

A Supervisor service is defined primarily by the command Supervisor will execute to run it. Supervisor will start the service at boot and restart it if it terminates for any reason.

There's a few differences in the command we're running from before. The --workers parameter to Gunicorn controls the number of worker processes, and thus the maximum number of clients that can be served at once. The Gunicorn documentation recommends setting the number of workers to the number of cores on the machine times two plus one.

We've also changed the bind address to localhost-only. Gunicorn, like most WSGI servers, is not suitable for direct exposure to the Internet, because worker processes will block on network I/O. It must be proxied behind a buffering proxy server to insulate it from the network.

Setting up a nginx proxy

Nginx is an event-based Web server and proxy. We'll use it as the frontend server to buffer requests to the Django site, as well as for serving the site's static files.

Create a new nginx module with the following in its init.pp manifest:

class nginx{
    include nginx::install, nginx::config, nginx::service
}

class nginx::install{
    package { 'nginx':
        ensure => present
    }
}

class nginx::config{
    file { "/etc/nginx/sites-enabled/":
        recurse => true,
        purge => true,
    }    
}

class nginx::service{
    service { 'nginx':
        ensure => running,
    }
}

Class["nginx::install"] -> Class["nginx::config"] -> Class["nginx::service"]

define nginx::site( $source ){
    file { "/etc/nginx/sites-enabled/$name":
        ensure => present,
        source => $source,
        owner => root,
        group => root,
        mode => 0644,
        require => Class['nginx'],
        notify => Class['nginx::service'],
    }
}

This manifest is fairly ordinary: installing Nginx, keeping the default configuration, starting the service, and defining the nginx::site type we can use in the django module.

Of note is the recursive purge of /etc/nginx/sites-enabled. We want to make sure any unmanaged files in that directory, such as the default file there at installation, are removed. Puppet will leave the files nginx::site places there alone.

Now, let's add the nginx::site resource to Django. Add the following to django::service:

include nginx
nginx::site{ 'django':
    source => 'puppet:///modules/django/django-nginx.conf',
}

We're referencing the file modules/django/files/django-nginx.conf for the site definition. Initialize this file with:

server {
    listen 80 default_server;

    location /static  {
        root /usr/local/app/django/var;
    }

    location / {
        proxy_pass_header Server;
        proxy_set_header Host $http_host;
        proxy_redirect off;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Scheme $scheme;
        proxy_connect_timeout 10;
        proxy_read_timeout 100;
        proxy_pass http://localhost:10001/;
    }
}

This Nginx configuration assumes your Django static URL prefix is /static, and that you are collecting static files into the directory var/static with respect to the project root.

Finishing up the Django module

We're almost done: to finish up the Django module we just need to define the main Django class and specify the dependency order:

class django {
    include django::install, django::clone, django::environment, django::service
}

Class["django::install"] -> Class["django::clone"] -> Class["django::enviroment"] -> Class["django::service"]

Just add include django to nodes.pp, apply the manifest, and Puppet will set up Gunicorn and Nginx. Connect to your virtual machine on port 80, and you should see your Django site.

Deploying to a real server

I promised at the beginning that Fabric would get a role in this tutorial, and here it does. We'll be using Fabric to bootstrap Puppet on the real server, and defining an action to apply a new manifest to it.

from fabric.api import *
from fabric.contrib.project import rsync_project

def apply():
    rsync_project(remote_dir='/usr/local/puppet', local_dir='.', extra_opts='--delete')
    sudo('puppet apply --modulepath /usr/local/puppet/modules /usr/local/puppet/manifests/site.pp')

def setup_client():
    sudo('apt-get update')
    sudo('apt-get install puppet')
    sudo('mkdir -p /usr/local/puppet')
    sudo('chown -R %s /usr/local/puppet' % env.user)

This file assumes the following directory structure:

+ puppet
    - fabfile.py
    + files
    + manifests
    + modules

To set up a new server, run fab -H server.example.com setup_client apply. This will install the puppet package, rsync up your manifests, and apply them to the server. To apply subsequent changes to the manifest, just run fab -H server.example.com apply.

This script places the manifests in /usr/local/puppet instead of Puppet's default /etc/puppet in order to avoid the serious hassle of setting up rsync to run as the superuser to have the necessary permissions to write to that directory.

This finishes up the server configuration. Like usual, you'll also want a Fabric file in your to deploy your application code. This is somewhat out of the scope of this tutorial, but here's a sample from one of my projects:

from fabric.api import *
def deploy():
    with cd('/usr/local/app'):
        sudo('git pull')
        sudo('bin/pip-install')
        sudo('./manage migrate')
        sudo('./manage collectstatic --noinput')
        sudo('supervisorctl restart django')

What now?

We've reached our goal of a Puppet manifest to configure a server for a Django project. I recommend trying a few of the exercises below to help solidify your understanding of basic Puppet usage; all can be completed with just the concepts I've covered.

To continue learning, I recommend consulting the official documentation. In particular, the built-in type reference contains all of the types, like user and file that Puppet understands. Other good places to start are templates for generating configuration files for software based on the manifest, and node definitions for customizing a manifest for multiple servers of different types.

Exercises

  • It's best for production settings to not be in the project repository, but the Puppet catalog is a perfect place for them. Add a file resource to the django module to manage a settings_production.py file in the project root, and run Gunicorn using those settings instead.
  • Some of the contents of the django module are not Django-specific, but common to any Python application, such as the python system package and django::virtualenv type. Factor these out into a python module and make the django class depend on the python class.
  • Speed up your Django site with a cache! Create a memcached or redis class that installs and configures a cache engine on your server.
  • Since we've hardcoded many things in the django class, it is impossible to run multiple Django sites on the same server. Refactor the django class into a define instead, which takes arguments for the repository and branch to clone, the location to install it, the settings file, and the nginx site file.