├── .gitignore ├── server ├── common.nix ├── logical.prod.nix ├── digital-ocean.keys.nix.sample ├── physical.gce.prod.nix ├── logical.vbox.nix ├── logical.libvirt.nix ├── physical.libvirt.nix ├── physical.vbox.nix ├── nginx.nix ├── physical.digital-ocean.prod.nix ├── gce.keys.nix.sample ├── physical.digital-ocean.nix ├── utils.nix ├── php-fpm-conf.nix ├── nginx-partials │ ├── security.conf │ ├── expires.conf │ └── compression.conf ├── physical.gce.nix ├── php-config.nix ├── install-wp.nix ├── opcache-config.nix ├── app.nix ├── logical.nix └── nginx-config.nix ├── wordpress-admin.keys.nix.sample ├── nixpkgs.nix ├── .git-crypt ├── .gitattributes └── keys │ └── default │ └── 0 │ └── AEB754C97689DDC8114643036927D7A5B754636C.gpg ├── .gitattributes ├── wordpress.nix ├── themes.nix ├── deploy ├── nixpkgs-version.sh ├── nixpkgs-version.nix └── manage ├── plugins.nix ├── wp-config.nix ├── SETUP-SECRETS.md ├── utils.nix ├── DEPLOY-DIGITAL-OCEAN.md ├── DEPLOY-GCE.md ├── default-app-config.nix └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | result 2 | *.nixops-* 3 | -------------------------------------------------------------------------------- /server/common.nix: -------------------------------------------------------------------------------- 1 | { 2 | machineName = "wordpress-main"; 3 | } 4 | -------------------------------------------------------------------------------- /server/logical.prod.nix: -------------------------------------------------------------------------------- 1 | import ./logical.nix (self: super: {}) 2 | -------------------------------------------------------------------------------- /server/digital-ocean.keys.nix.sample: -------------------------------------------------------------------------------- 1 | { 2 | apiAuthToken = "..."; # a DigitalOcean API auth token with write privileges. 3 | } 4 | -------------------------------------------------------------------------------- /wordpress-admin.keys.nix.sample: -------------------------------------------------------------------------------- 1 | { 2 | # Configure credentials for the superadmin in Wordpress 3 | adminUser = "..."; 4 | adminPassword = "..."; 5 | } 6 | -------------------------------------------------------------------------------- /nixpkgs.nix: -------------------------------------------------------------------------------- 1 | # Import this instead of to get the repo-specific version of nixpkgs. 2 | 3 | import ((import {}).fetchzip (import deploy/nixpkgs-version.nix)) -------------------------------------------------------------------------------- /server/physical.gce.prod.nix: -------------------------------------------------------------------------------- 1 | import ./physical.gce.nix { 2 | credentials = import ./gce.keys.nix; 3 | machineRegion = "us-west1-a"; 4 | staticIpRegion = "us-west1"; 5 | } 6 | -------------------------------------------------------------------------------- /.git-crypt/.gitattributes: -------------------------------------------------------------------------------- 1 | # Do not edit this file. To specify the files to encrypt, create your own 2 | # .gitattributes file in the directory where your files are. 3 | * !filter !diff 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.secret filter=git-crypt diff=git-crypt 2 | *.keys.nix filter=git-crypt diff=git-crypt 3 | *.pem filter=git-crypt diff=git-crypt 4 | *.nixops binary filter=git-crypt 5 | -------------------------------------------------------------------------------- /.git-crypt/keys/default/0/AEB754C97689DDC8114643036927D7A5B754636C.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafted-in/wordpress-nginx-nix/HEAD/.git-crypt/keys/default/0/AEB754C97689DDC8114643036927D7A5B754636C.gpg -------------------------------------------------------------------------------- /wordpress.nix: -------------------------------------------------------------------------------- 1 | { fetchzip, runCommand, ... }: 2 | let 3 | version = "4.8"; 4 | in fetchzip { 5 | url = "https://wordpress.org/wordpress-${version}.tar.gz"; 6 | sha256 = "1myflpa9pxcghnhjfd0ahqpsvgcwh3szk2k8w2x7qmvfll69n3j9"; 7 | } 8 | -------------------------------------------------------------------------------- /server/logical.vbox.nix: -------------------------------------------------------------------------------- 1 | import ./logical.nix (self: super: { 2 | domain = "testsite.dev"; 3 | host = self.domain; 4 | hostRedirects = []; 5 | enableHttps = false; 6 | enableRollback = false; 7 | enableXDebug = true; 8 | }) 9 | -------------------------------------------------------------------------------- /server/logical.libvirt.nix: -------------------------------------------------------------------------------- 1 | import ./logical.nix (self: super: { 2 | domain = "testsite.dev"; 3 | host = self.domain; 4 | hostRedirects = []; 5 | enableHttps = false; 6 | enableRollback = false; 7 | enableXDebug = true; 8 | }) 9 | -------------------------------------------------------------------------------- /themes.nix: -------------------------------------------------------------------------------- 1 | # A list of your WordPress themes. 2 | { callPackage, ... }: 3 | let 4 | utils = callPackage ./utils.nix {}; 5 | getTheme = utils.getTheme; 6 | in [ 7 | (getTheme "twentyseventeen" "1.1" "1xsdz1s68mavz9i4lhckh7rqw266jqm5mn3ql1gbz03zf6ghf982") 8 | ] 9 | -------------------------------------------------------------------------------- /deploy/nixpkgs-version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) 4 | 5 | nixpkgs_snapshot=$(eval echo "$(nix-instantiate --eval -E "(import \"$here/nixpkgs-version.nix\").url")") 6 | export nixpkgs_snapshot 7 | export nixops_version="nixops" 8 | -------------------------------------------------------------------------------- /server/physical.libvirt.nix: -------------------------------------------------------------------------------- 1 | with import ./common.nix; 2 | 3 | let 4 | machineTemplate = memoryMb: { 5 | deployment.targetEnv = "libvirtd"; 6 | deployment.libvirtd = { 7 | headless = true; 8 | memorySize = memoryMb; 9 | }; 10 | }; 11 | in { 12 | ${machineName} = machineTemplate 1024; 13 | } 14 | -------------------------------------------------------------------------------- /server/physical.vbox.nix: -------------------------------------------------------------------------------- 1 | with import ./common.nix; 2 | 3 | let 4 | machineTemplate = memoryMb: { 5 | deployment.targetEnv = "virtualbox"; 6 | deployment.virtualbox = { 7 | headless = true; 8 | memorySize = memoryMb; 9 | }; 10 | }; 11 | in { 12 | ${machineName} = machineTemplate 1024; 13 | } 14 | -------------------------------------------------------------------------------- /server/nginx.nix: -------------------------------------------------------------------------------- 1 | { callPackage, lib, nginxModules, enablePageSpeed, ... }: 2 | callPackage { 3 | modules = [ 4 | nginxModules.dav 5 | nginxModules.fastcgi-cache-purge 6 | nginxModules.moreheaders 7 | ] 8 | ++ lib.optional enablePageSpeed nginxModules.pagespeed 9 | ; 10 | } 11 | -------------------------------------------------------------------------------- /server/physical.digital-ocean.prod.nix: -------------------------------------------------------------------------------- 1 | import ./physical.digital-ocean.nix { 2 | apiAuthToken = (import ./digital-ocean.keys.nix).apiAuthToken; 3 | dropletRegion = "nyc3"; # https://developers.digitalocean.com/documentation/v2/#list-all-regions 4 | dropletSize = "512mb"; # https://developers.digitalocean.com/documentation/v2/#list-all-sizes 5 | } 6 | -------------------------------------------------------------------------------- /server/gce.keys.nix.sample: -------------------------------------------------------------------------------- 1 | { 2 | project = "..."; # project identifiers look like "random-word-123456" 3 | serviceAccount = "..."; # looks like ...@{project}.iam.gserviceaccount.com 4 | 5 | # path to .pem file as a string; if a relative path, you must run deployments from a working 6 | # directory that will resolve this path correctly. 7 | accessKey = "..."; 8 | } 9 | -------------------------------------------------------------------------------- /server/physical.digital-ocean.nix: -------------------------------------------------------------------------------- 1 | with import ./common.nix; 2 | 3 | { apiAuthToken, dropletRegion, dropletSize }: { 4 | resources.sshKeyPairs.ssh-key = {}; 5 | 6 | ${machineName} = {...}: { 7 | deployment = { 8 | targetEnv = "digitalOcean"; 9 | digitalOcean = { 10 | authToken = apiAuthToken; 11 | region = dropletRegion; 12 | size = dropletSize; 13 | }; 14 | }; 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /deploy/nixpkgs-version.nix: -------------------------------------------------------------------------------- 1 | # Check out different Nixpkgs channels here: 2 | # * http://howoldis.herokuapp.com/ 3 | # * https://nixos.org/channels/ 4 | # 5 | # To upgrade: 6 | # 1. Choose a channel and click on it. 7 | # 2. Get the URL of the `nixexprs.tar.xz` file for the channel. 8 | # 4. Paste the URL below for `url`. 9 | # 5. Get SHA256 hash of URL contents with `nix-prefetch-url --unpack `. 10 | 11 | { 12 | url = "https://d3g5gsiof5omrk.cloudfront.net/nixpkgs/nixpkgs-17.03pre101896.4a524cf/nixexprs.tar.xz"; 13 | sha256 = "1wrm9k0plpzz0wi94ry1xv1v3aq4vs20v5dzxv4azn4i8vhf7wmg"; 14 | } -------------------------------------------------------------------------------- /plugins.nix: -------------------------------------------------------------------------------- 1 | # A list of your WordPress plugins. 2 | { callPackage, ... }: 3 | let 4 | utils = callPackage ./utils.nix {}; 5 | getPlugin = utils.getPlugin; 6 | 7 | requiredPlugins = [ 8 | (getPlugin "opcache" "0.3.1" "18x6fnfc7ka4ynxv4z3rf4011ivqc0qy0dsd6i4lxa113jjyqz6d") 9 | (getPlugin "nginx-helper" "1.9.10" "1n887qz9rzs8yj069wva6cirp6y46a49wspzja4grdj2qirr4hky") 10 | ]; 11 | in requiredPlugins ++ [ 12 | (getPlugin "akismet" "3.3" "02vsjnr7bs54a744p64rx7jwlbcall6nhh1mv6w54zbwj4ygqz68") 13 | (getPlugin "jetpack" "4.8.2" "17bvkcb17dx969a30j0axb5kqzfxnx1sqkcdwwrski9gh7ihabqk") 14 | ] 15 | -------------------------------------------------------------------------------- /server/utils.nix: -------------------------------------------------------------------------------- 1 | { 2 | traced = x: builtins.trace x x; 3 | required = arg: help: builtins.abort "${arg} is required: ${help}"; 4 | 5 | # Converts a set into a string 6 | # pkgs is nixpkgs 7 | # sep is a string separator to place between each field 8 | # mapFn is (string -> string -> string) taking key and value for each attribute 9 | # attrs is the set to process 10 | setToString = pkgs: sep: mapFn: attrs: pkgs.lib.concatStringsSep sep ( 11 | pkgs.lib.mapAttrsToList 12 | (key: val: if builtins.isInt val || builtins.isString val then mapFn key val else "") 13 | attrs 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /wp-config.nix: -------------------------------------------------------------------------------- 1 | { dbConfig 2 | , secrets 3 | , debugMode ? false 4 | , extraConfig 5 | , ... 6 | }: 7 | '' 8 | appConfig.maxUploadMb; 7 | 8 | '' 9 | memory_limit ${toString appConfig.php.scriptMemoryLimitMb}M 10 | 11 | extension = "${pkgs.phpPackages.imagick}/lib/php/extensions/imagick.so" 12 | 13 | ${pkgs.lib.optionalString appConfig.opcache.enable '' 14 | zend_extension = "${config.services.phpfpm.phpPackage}/lib/php/extensions/opcache.so" 15 | ''} 16 | 17 | ${pkgs.lib.optionalString appConfig.php.enableXDebug '' 18 | ; WARNING: Be sure to load opcache *before* xdebug (http://us3.php.net/manual/en/opcache.installation.php). 19 | zend_extension = "${pkgs.phpPackages.xdebug}/lib/php/extensions/xdebug.so" 20 | ''} 21 | 22 | upload_max_filesize = ${toString appConfig.maxUploadMb}M 23 | post_max_size = ${toString appConfig.maxUploadMb}M 24 | max_execution_time ${toString appConfig.php.maxExecutionTimeSec} 25 | 26 | date.timezone = "${appConfig.timezone}" 27 | sendmail_path = ${appConfig.php.sendmailPath} 28 | 29 | ${import ./opcache-config.nix (appConfig.opcache // { 30 | # Enable timestamp validation if the setup is not entirely frozen (managed by Nix). 31 | validateTimestamps = ! builtins.all (x: x) 32 | [appConfig.freezeWordPress appConfig.freezePlugins appConfig.freezeThemes]; 33 | })} 34 | '' 35 | -------------------------------------------------------------------------------- /server/install-wp.nix: -------------------------------------------------------------------------------- 1 | { pkgs 2 | , config # system configuration 3 | , appConfig 4 | , appPackage 5 | , writeableDataPath 6 | }: 7 | pkgs.writeScript "install-wordpress.sh" '' 8 | #!${pkgs.stdenv.shell} -eu 9 | 10 | if ! $('${pkgs.wp-cli}/bin/wp' core is-installed --path='${appPackage}' --allow-root); then 11 | echo 'Installing WordPress configuration for ${appConfig.host}' 12 | '${pkgs.wp-cli}/bin/wp' core install \ 13 | --url='${appConfig.siteUrl}' \ 14 | --title='${appConfig.description}' \ 15 | --admin_user='${appConfig.autoInstall.adminUser}' \ 16 | --admin_password='${appConfig.autoInstall.adminPassword}' \ 17 | --admin_email='${appConfig.adminEmail}' \ 18 | --path='${appPackage}' \ 19 | --allow-root; 20 | chown -R '${config.services.nginx.user}' '${writeableDataPath}'; 21 | else 22 | echo 'WordPress configuration already installed for ${appConfig.host}' 23 | fi 24 | 25 | '${pkgs.wp-cli}/bin/wp' option update blogname '${appConfig.description}' \ 26 | --path='${appPackage}' \ 27 | --allow-root; 28 | 29 | '${pkgs.wp-cli}/bin/wp' option update blogdescription '${appConfig.tagline}' \ 30 | --path='${appPackage}' \ 31 | --allow-root; 32 | 33 | # TODO: Provide a list of plugins to be activated from plugins.nix 34 | '${pkgs.wp-cli}/bin/wp' plugin activate nginx-helper opcache \ 35 | --path='${appPackage}' \ 36 | --allow-root; 37 | '' 38 | -------------------------------------------------------------------------------- /server/nginx-partials/expires.conf: -------------------------------------------------------------------------------- 1 | # HTML5 Boilerplate from - https://github.com/h5bp/server-configs-nginx 2 | 3 | # Expire rules for static content 4 | 5 | # No default expire rule. This config mirrors that of apache as outlined in the 6 | # html5-boilerplate .htaccess file. However, nginx applies rules by location, 7 | # the apache rules are defined by type. A consequence of this difference is that 8 | # if you use no file extension in the url and serve html, with apache you get an 9 | # expire time of 0s, with nginx you'd get an expire header of one month in the 10 | # future (if the default expire rule is 1 month). Therefore, do not use a 11 | # default expire rule with nginx unless your site is completely static 12 | 13 | # cache.appcache, your document html and data 14 | location ~* \.(?:manifest|appcache|html?|json)$ { 15 | expires -1; 16 | #access_log /var/log/nginx/static.log; 17 | } 18 | 19 | # Feed 20 | location ~* \.(?:rss|atom)$ { 21 | expires 1h; 22 | add_header Cache-Control "public"; 23 | } 24 | 25 | # Media: images, icons, video, audio, HTC 26 | location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc|webp)$ { 27 | etag off; 28 | expires 1M; 29 | access_log off; 30 | add_header Cache-Control "public"; 31 | } 32 | 33 | # CSS and Javascript 34 | location ~* \.(?:css|js)$ { 35 | etag off; 36 | expires 1y; 37 | access_log off; 38 | add_header Cache-Control "public"; 39 | } 40 | 41 | # WebFonts 42 | location ~* \.(?:ttf|ttc|otf|eot|woff|woff2)$ { 43 | etag off; 44 | add_header "Access-Control-Allow-Origin" "*"; 45 | expires 1M; 46 | access_log off; 47 | add_header Cache-Control "public"; 48 | } 49 | -------------------------------------------------------------------------------- /server/opcache-config.nix: -------------------------------------------------------------------------------- 1 | # References: 2 | # * https://www.rfmeier.net/speed-up-wordpress-dreamhost-opcache/ 3 | # * http://us3.php.net/manual/en/opcache.installation.php 4 | # * https://tideways.io/profiler/blog/fine-tune-your-opcache-configuration-to-avoid-caching-suprises 5 | 6 | { enable, maxMemoryMb, validateTimestamps, revalidateFreqSec, ... }: 7 | '' 8 | opcache.enable=${if enable then "1" else "0"} 9 | opcache.memory_consumption=${toString maxMemoryMb} ; MB 10 | opcache.interned_strings_buffer=8 ; The amount of memory to store immutable strings 11 | opcache.save_comments=1 ; Comments in code will be compiled 12 | opcache.load_comments=0 ; Comments will not be loaded 13 | opcache.max_file_size=2097152 14 | opcache.fast_shutdown=0 15 | opcache.max_accelerated_files=10000 16 | opcache.enable_cli=1 17 | 18 | ; Make sure each cached file has a distinct path 19 | ; See http://php.net/manual/en/opcache.configuration.php#ini.opcache.revalidate-path 20 | opcache.revalidate_path=1 21 | 22 | ; Check opcace when PHP uses file checking functions, file_exists, etc 23 | ; See http://php.net/manual/en/opcache.configuration.php#ini.opcache.enable-file-override 24 | opcache.enable_file_override=1 25 | 26 | ; Cache expiration. 27 | ; See http://php.net/manual/en/opcache.configuration.php#ini.opcache.validate-timestamps 28 | opcache.validate_timestamps=${if validateTimestamps then "1" else "0"} 29 | 30 | ; How long to check a file if it needs to be re-cached 31 | ; If opcache.validate_timestamps is disabled, this is ignored. 32 | ; See http://php.net/manual/en/opcache.configuration.php#ini.opcache.revalidate-freq 33 | opcache.revalidate_freq=${toString revalidateFreqSec} 34 | '' 35 | -------------------------------------------------------------------------------- /SETUP-SECRETS.md: -------------------------------------------------------------------------------- 1 | Setting Up Secrets 2 | ================== 3 | 4 | ## `.gitignore` configuration 5 | 6 | Your `.gitignore` file can mimic the one for this repository, but should at least include the following entries: 7 | 8 | # Nix results and temporary deployment files. 9 | result 10 | *.nixops-* 11 | 12 | ## `git-crypt` setup 13 | 14 | Unless you're using a custom solution (e.g. [vault](https://github.com/hashicorp/vault)) for deployment data, deployments will require that you store secrets in your repository. To achieve this, you will need to install [git-crypt](https://www.agwa.name/projects/git-crypt/). 15 | 16 | `git-crypt` can use a shared secret or rely on [PGP](https://en.wikipedia.org/wiki/Pretty_Good_Privacy) identities to securely grant access to the appropriate users. Configuring `git-crypt` is outside the scope of this project, but it's well worth learning if you don't know it already. (It's not that much work to use.) [Keybase](https://keybase.io/) offers excellent tooling and help in the realm of PGP identities and security. 17 | 18 | ### `.gitattributes` configuration 19 | 20 | For `git-crypt`, you will also need to properly configure your `.gitattributes` file. The `.gitattributes` file for this repository serves as a good example. Most importantly, it must contain the following entry: 21 | 22 | 23 | # Encrypt all deployment data. 24 | *.nixops binary filter=git-crypt diff=git-crypt 25 | 26 | ### Granting PGP access to yourself via Keybase 27 | 28 | If you're using Keybase, you grant access to yourself by importing your own key: 29 | 30 | ```shell 31 | keybase pgp export | gpg --import 32 | keybase pgp export --secret | gpg --allow-secret-key-import --import 33 | git-crypt add-gpg-user 34 | ``` 35 | 36 | ### Granting PGP access to a Keybase user 37 | 38 | If you're using Keybase, you can grant access to a user like this: 39 | 40 | ```shell 41 | keybase pgp pull 42 | gpg --edit-key 43 | > lsign 44 | > save 45 | git-crypt add-gpg-user 46 | ``` 47 | -------------------------------------------------------------------------------- /utils.nix: -------------------------------------------------------------------------------- 1 | { fetchzip, runCommand, unzip, ... }: { 2 | # Builds a package from a zip archive. 3 | # The archive must have exactly one, top-level directory which will stripped. 4 | # Example: zipArchive "my-package-name" ./my-package.zip 5 | zipArchive = name: path: runCommand name { buildInputs = [ unzip ]; } '' 6 | unzip "${path}" -d "$TEMPDIR" 7 | top_dir=$(find "$TEMPDIR" -type d -mindepth 1 -maxdepth 1) 8 | if [ "$(echo "$top_dir" | wc -l)" -ne 1 ]; then 9 | echo Archive must have exactly one top-level directory. 10 | exit 1 11 | fi 12 | 13 | mv "$top_dir" "$out" 14 | ''; 15 | 16 | # Builds a package from a folder. 17 | # Example: folder "my-package-name" ./my-package 18 | folder = name: path: runCommand name {} '' 19 | ln -s "${path}" "$out" 20 | ''; 21 | 22 | # Builds a package from a registered WordPress plugin. 23 | # Example: getPlugin "akismet" "3.2" "0ri9a0lbr269r3crmsa6hn4v4nd4dyblrb0ffvkmig2pvvx25hyn" 24 | # To determine the name, version, and SHA256 hash of a plugin, find it on 25 | # https://wordpress.org/plugins and look at the URL of the "Download" button. Most URLs will tell 26 | # you the name and version. To determine the hash, install `nix-prefetch-zip` 27 | # (via `nix-env -i nix-prefetch-zip`) and run it on the plugin URL: 28 | # `nix-prefetch-zip `. 29 | getPlugin = name: version: sha256: fetchzip { 30 | inherit name sha256; 31 | url = "https://downloads.wordpress.org/plugin/${name}.${version}.zip"; 32 | }; 33 | 34 | # Builds a package from a registered WordPress theme. 35 | # Example: getTheme "twentyseventeen" "1.0" "01779xz4c3b1drv3v2d1p1rdh1w9a0wsxjxpvp4nzwm26h7bvg7n" 36 | # To determine the name, version, and SHA256 hash of a theme, find it on 37 | # https://wordpress.org/themes and look at the URL of the "Download" button. Most URLs will tell 38 | # you the name and version. To determine the hash, install `nix-prefetch-zip` 39 | # (via `nix-env -i nix-prefetch-zip`) and run it on the theme URL: 40 | # `nix-prefetch-zip `. 41 | getTheme = name: version: sha256: fetchzip { 42 | inherit name sha256; 43 | url = "https://downloads.wordpress.org/theme/${name}.${version}.zip"; 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /server/nginx-partials/compression.conf: -------------------------------------------------------------------------------- 1 | # HTML5 Boilerplate from - https://github.com/h5bp/server-configs-nginx 2 | 3 | # Compression 4 | 5 | # Enable Gzip compressed. 6 | gzip on; 7 | 8 | # Compression level (1-9). 9 | # 5 is a perfect compromise between size and cpu usage, offering about 10 | # 75% reduction for most ascii files (almost identical to level 9). 11 | gzip_comp_level 5; 12 | 13 | # Don't compress anything that's already small and unlikely to shrink much 14 | # if at all (the default is 20 bytes, which is bad as that usually leads to 15 | # larger files after gzipping). 16 | gzip_min_length 256; 17 | 18 | # Compress data even for clients that are connecting to us via proxies, 19 | # identified by the "Via" header (required for CloudFront). 20 | gzip_proxied any; 21 | 22 | # Tell proxies to cache both the gzipped and regular version of a resource 23 | # whenever the client's Accept-Encoding capabilities header varies; 24 | # Avoids the issue where a non-gzip capable client (which is extremely rare 25 | # today) would display gibberish if their proxy gave them the gzipped version. 26 | gzip_vary on; 27 | 28 | # Compress all output labeled with one of the following MIME-types. 29 | gzip_types 30 | application/atom+xml 31 | application/javascript 32 | application/json 33 | application/ld+json 34 | application/manifest+json 35 | application/rss+xml 36 | application/vnd.geo+json 37 | application/vnd.ms-fontobject 38 | application/x-font-ttf 39 | application/x-web-app-manifest+json 40 | application/xhtml+xml 41 | application/xml 42 | font/opentype 43 | image/bmp 44 | image/svg+xml 45 | image/x-icon 46 | text/cache-manifest 47 | text/css 48 | text/plain 49 | text/vcard 50 | text/vnd.rim.location.xloc 51 | text/vtt 52 | text/x-component 53 | text/x-cross-domain-policy; 54 | # text/html is always compressed by HttpGzipModule 55 | 56 | # This should be turned on if you are going to have pre-compressed copies (.gz) of 57 | # static files available. If not it should be left off as it will cause extra I/O 58 | # for the check. It is best if you enable this in a location{} block for 59 | # a specific directory, or on an individual server{} level. 60 | # gzip_static on; 61 | -------------------------------------------------------------------------------- /DEPLOY-DIGITAL-OCEAN.md: -------------------------------------------------------------------------------- 1 | Deploying to DigitalOcean 2 | ========================= 3 | 4 | ## Deploying to a DigitalOcean account 5 | 6 | If you want to deploy this server to a DigitalOcean account, use the following instructions to configure it: 7 | 8 | ### Get an API token 9 | 10 | Create an API token for the deployment: 11 | 12 | 1. In your DigitalOcean account, click **API** in the topmost menu. 13 | 2. Open the **Tokens** tab. 14 | 3. Click *Generate New Token*. 15 | 4. Give your token a name that will describe this project and grant it both "Read" and "Write" scopes. 16 | 5. Click *Generate Token* and copy the resulting token. 17 | 18 | Configuring deployment: 19 | 20 | 1. Copy `server/digital-ocean.keys.nix.sample` to `server/digital-ocean.keys.nix`. 21 | 2. Replace the `...` with your API token. 22 | 23 | ### Deploying 24 | 25 | To deploy to production, you will use similar steps as deploying to VirtualBox (see the README): 26 | 27 | 1. Configure `server/physical.digital-ocean.prod.nix` to your preferences. 28 | 2. `deploy/manage prod create '' ''` 29 | 3. `DIGITAL_OCEAN_AUTH_TOKEN= deploy/manage prod deploy` 30 | * Note: We need to use an environment variable to specify the API token for the time being; future versions of `nixops` will not require this. 31 | 32 | It may take a long time to build the server and upload all the dependencies. 33 | 34 | **IMPORTANT:** You **must** keep the deployment state in `deploy/prod.nixops` up-to-date in the repository. Once you run the `deploy/manage prod deploy` command above, you must commit that file and always commit it any time you do a deployment that causes it to change. The `deploy/manage` script is designed to keep these state files up-to-date on every deployment so that you can be sure to have the right file in your repository. Do not allow simultaneous deployments and always use the deployment state file that actually corresponds to the state of the server. 35 | 36 | 37 | ### Using an existing deployment 38 | 39 | Once you've made a deployment and committed its `.nixops` file to the repository, anyone on your team can deploy who has `git-crypt` access to the file. The steps are just like before: 40 | 41 | 1. `deploy/manage prod info` (get info about the production deployment) 42 | 2. `deploy/manage prod deploy` (deploy to production) 43 | 3. `git add deploy/prod.nixops && git commit -m"Deployment"` 44 | -------------------------------------------------------------------------------- /DEPLOY-GCE.md: -------------------------------------------------------------------------------- 1 | Deploying to Google Compute Engine (GCE) 2 | ======================================== 3 | 4 | ## Deploying to a new GCE account 5 | 6 | If you want to deploy this server to a new GCE account, use the following instructions to configure it: 7 | 8 | ### Setting up a new GCE project 9 | 10 | Create a Google Cloud Compute project: 11 | 12 | 1. Create an account with Google Cloud Compute or sign in with an existing account. 13 | 2. Go to the **Console** for your account. 14 | 3. Under the **Project** menu click *Create Project*, provide a project name, and create the project. 15 | 4. Once created the new project should be selected as the current project in the console. 16 | 17 | Create a service account that will run the deployment: 18 | 19 | 1. While in your project on Google Cloud Platform, under the "hamburger menu" (pop-in sidebar), select **IAM & Admin** then select *Service accounts*. 20 | 2. Click *CREATE SERVICE ACCOUNT* at the top. 21 | 3. Choose a name for your new service account that reminds you of its function: a deployment manager. 22 | 4. Select both the *Editor* and *Viewer* roles. 23 | 5. Enable the *Furnish a new private key* setting and select the P12 format. 24 | 6. Create the account and remember the key passphrase (probably `notasecret`). 25 | 7. Convert the P12 key to a PEM key by running the following, replacing `{key}` with the name of your key file (without the extension) and `{notasecret}` with the key password: 26 | `openssl pkcs12 -in {key}.p12 -passin pass:{notasecret} -nodes -nocerts | openssl rsa -out {key}.pem` 27 | 28 | Configuring deployment: 29 | 30 | 1. Copy `server/gce.keys.nix.sample` to `server/gce.keys.nix`. 31 | 2. Replace the `...` with your project and credentials. 32 | 33 | 34 | ### Deploying 35 | 36 | To deploy to production, you will use similar steps as deploying to VirtualBox (see the README): 37 | 38 | 1. Configure `server/physical.gce.prod.nix` to your preferences. 39 | 2. `deploy/manage prod create '' ''` 40 | 3. `(cd deploy && ./manage prod deploy)` 41 | 42 | It may take a long time to build the server and upload all the dependencies. 43 | 44 | **IMPORTANT:** You **must** keep the deployment state in `deploy/prod.nixops` up-to-date in the repository. Once you run the `deploy/manage prod deploy` command above, you must commit that file and always commit it any time you do a deployment that causes it to change. The `deploy/manage` script is designed to keep these state files up-to-date on every deployment so that you can be sure to have the right file in your repository. Do not allow simultaneous deployments and always use the deployment state file that actually corresponds to the state of the server. 45 | 46 | 47 | ### Using an existing deployment 48 | 49 | Once you've made a deployment and committed its `.nixops` file to the repository, anyone on your team can deploy who has `git-crypt` access to the file. The steps are just like before: 50 | 51 | 1. `deploy/manage prod info` (get info about the production deployment) 52 | 2. `(cd deploy && ./manage prod deploy)` (deploy to production) 53 | 3. `git add deploy/prod.nixops && git commit -m"Deployment"` 54 | -------------------------------------------------------------------------------- /deploy/manage: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # A NixOps Wrapper for Git Projects 4 | # --------------------------------- 5 | # 6 | # Repo: https://github.com/grafted-in/nixops-manager 7 | # 8 | # This tool is a simple wrapper around NixOps. The goal is to make it easier to use NixOps when you 9 | # want to share your deployment state between members of a team. 10 | # 11 | # To achieve this, this wrapper gives every deployment as a separate state file which is placed 12 | # in the same directory as this script. The files have the `.nixops` extension. 13 | # 14 | # You are expected to keep these files in version control. It's also *highly* recommended that you 15 | # use a tool like git-crypt to keep them encrypted with this entry in .gitattributes: 16 | # 17 | # *.nixops binary filter=git-crypt diff=git-crypt 18 | # 19 | # This tool also enforces a per-repository version of Nixpkgs via a `nixpkgs-version.sh` file in the 20 | # same directory as the script. This ensures that all users have a consistent version of NixOps and 21 | # deploy a consistent set of packages to servers. 22 | # 23 | # Most commands work identically to NixOps. However, instead of specifying deployments with 24 | # the `--deployment/-d` flag, you select a deployment in the first argument. In other words, instead 25 | # of the normal NixOps usage of 26 | # 27 | # nixops deploy -d stage --check # Normal nixops usage. 28 | # 29 | # You'd run: 30 | # 31 | # ./manage stage deploy --check # Manage script usage. 32 | # 33 | # This assume there is a file ./stage.nixops where this state is being stored. 34 | # 35 | # Use `./manage --help` to see normal NixOps help. 36 | # Use `./manage {deployment} .shell` to open a Nix shell where the environment is set up to use 37 | # `nixops` directly with the same behavior as running `./manage` commands. 38 | 39 | set -e 40 | 41 | # Check for Nix tools. 42 | command -v nix-shell >/dev/null 2>&1 || { 43 | nix_profile="$HOME/.nix-profile/etc/profile.d/nix.sh" 44 | if [ -e "$nix_profile" ]; then 45 | source "$nix_profile" 46 | else 47 | >&2 echo "Failed to find 'nix-shell' on PATH or a Nix profile to load. Have you installed Nix?" 48 | exit 1 49 | fi 50 | } 51 | 52 | here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) 53 | repo_root=$(cd "$here" && git rev-parse --show-toplevel) # Use Git to find repo root. 54 | 55 | deployment="$1" 56 | command="$2" 57 | state_file="$here/${deployment}.nixops" 58 | 59 | source "$here/nixpkgs-version.sh" 60 | 61 | export NIX_PATH=nixpkgs="$nixpkgs_snapshot":"$repo_root":. 62 | export NIXOPS_STATE="$state_file" 63 | export NIXOPS_DEPLOYMENT="$deployment" 64 | 65 | withNixops="nix-shell -p $nixops_version --run" 66 | 67 | # Arg list trick: 68 | # https://stackoverflow.com/questions/3104209 69 | # ARGS=$(printf "%q"" " "$@") 70 | 71 | if [[ $deployment == --* ]]; then 72 | ARGS=$(printf "%q"" " "$@") 73 | $withNixops "nixops $ARGS" 74 | exit $? 75 | elif [ "$command" == ".shell" ]; then 76 | nix-shell -p "$nixops_version" 77 | elif [ ! -e "$state_file" ] && [ "$command" != "create" ]; then 78 | >&2 echo "You're trying to use a deployment that doesn't exist yet. Try running $0 $deployment create" 79 | exit 1 80 | elif [ -e "$state_file" ] && [ "$command" == "create" ]; then 81 | >&2 echo "You're trying to create a deployment that already exists." 82 | exit 1 83 | else 84 | ARGS=$(printf "%q"" " "${@:2}") 85 | $withNixops "nixops $ARGS" 86 | fi -------------------------------------------------------------------------------- /default-app-config.nix: -------------------------------------------------------------------------------- 1 | let 2 | lib = (import {}).lib; 3 | in lib.makeExtensible (self: { 4 | domain = "wordpress-site.dev"; 5 | 6 | # Simple name used for directories, etc. 7 | # WARNING: Changing this after a deployment will change the location of data directories and will 8 | # likely result in a partial reset of your application. You must move data from the 9 | # previous app folders to the new ones. 10 | name = "wordpress-app"; 11 | 12 | description = "A Wordpress Site"; # Brief, one-line description or title 13 | tagline = "Deployed with Nixops"; 14 | host = "www.${self.domain}"; 15 | adminEmail = "admin@${self.domain}"; 16 | 17 | siteUrl = "${if self.enableHttps then "https" else "http"}://${self.host}"; 18 | 19 | # Hosts that get redirected to the primary host. 20 | hostRedirects = [self.domain]; 21 | 22 | # Configure timezone settings (http://php.net/manual/en/timezones.php) 23 | timezone = "UTC"; 24 | 25 | # WP-CLI settings for automatic install 26 | autoInstall = let 27 | adminConfig = import ./wordpress-admin.keys.nix; 28 | in lib.makeExtensible (innerSelf: { 29 | enable = false; # set to `true` to automatically install WordPress configuration 30 | inherit (adminConfig) adminUser adminPassword; 31 | }); 32 | 33 | wordpress = import ./wordpress.nix; 34 | plugins = import ./plugins.nix; 35 | themes = import ./themes.nix; 36 | 37 | # Warning: Changing these after your site has been deployed will require manual 38 | # work on the server. We don't want to do anything that would lose 39 | # data so we leave that to you. 40 | freezeWordPress = true; # Can admins upgrade WordPress in the CMS? 41 | freezePlugins = true; # Can admins edit plugins in the CMS? 42 | freezeThemes = true; # Can admins edit themes in the CMS? 43 | 44 | dbConfig = lib.makeExtensible (innerSelf: { 45 | isLocal = true; # if `true`, MySQL will be installed on the server. 46 | name = "wordpress"; # database name 47 | user = "root"; 48 | password = ""; 49 | host = "localhost"; 50 | charset = "utf8mb4"; 51 | tablePrefix = "wp_"; 52 | }); 53 | 54 | wpConfig = lib.makeExtensible (innerSelf: { 55 | # Generate this file with `curl https://api.wordpress.org/secret-key/1.1/salt/ > wordpress-keys.php.secret` 56 | secrets = builtins.readFile ./wordpress-keys.php.secret; 57 | debugMode = false; 58 | extraConfig = '' 59 | define('WP_HOME', '${self.siteUrl}'); 60 | define('WP_SITEURL', '${self.siteUrl}'); 61 | ''; 62 | 63 | inherit (self) dbConfig; 64 | 65 | template = import ./wp-config.nix; 66 | rendered = innerSelf.template innerSelf; 67 | }); 68 | 69 | 70 | # Server settings 71 | enableHttps = true; 72 | enableRollback = true; 73 | maxUploadMb = 50; 74 | 75 | # --- ADVANCED CONFIGURATION --- 76 | imports = []; # module imports for the server 77 | 78 | # raw nginx location directives to insert above the WordPress locations 79 | extraNginxLocations = []; 80 | 81 | opcache = lib.makeExtensible (innerSelf: { 82 | enable = true; 83 | maxMemoryMb = 128; 84 | 85 | # How often to invalidate timestamp cache. This is only used when the project 86 | # has non-frozen components (see above). 87 | # http://php.net/manual/en/opcache.configuration.php#ini.opcache.revalidate-freq 88 | revalidateFreqSec = 60; 89 | }); 90 | 91 | # PHP-FPM settings for the *dynamic* process manager: http://php.net/manual/en/install.fpm.configuration.php#pm 92 | phpFpmProcessSettings = lib.makeExtensible (innerSelf: { 93 | max_children = 10; 94 | start_servers = innerSelf.min_spare_servers; # WARNING: min_spare_servers <= start_servers <= max_spare_servers 95 | min_spare_servers = 2; 96 | max_spare_servers = 5; 97 | max_requests = 500; 98 | }); 99 | 100 | googlePageSpeed = lib.makeExtensible (innerSelf: { 101 | enable = true; 102 | cachePath = "/run/nginx-pagespeed-cache"; # /run/ is tmpfs and will keep cache in memory 103 | }); 104 | 105 | fastCgiCache = lib.makeExtensible (innerSelf: { 106 | enable = true; 107 | cachePath = "/run/nginx-fastcgi-cache"; # /run/ is tmpfs and will keep cache in memory 108 | }); 109 | 110 | php = lib.makeExtensible (innerSelf: { 111 | enableXDebug = false; 112 | 113 | scriptMemoryLimitMb = 128; 114 | maxExecutionTimeSec = 300; 115 | 116 | # sendmail_path configuration for php.ini files 117 | sendmailPath = "/run/wrappers/bin/sendmail -t -i"; 118 | }); 119 | }) 120 | -------------------------------------------------------------------------------- /server/app.nix: -------------------------------------------------------------------------------- 1 | with import ./utils.nix; 2 | let 3 | writeableDefault = { 4 | appPaths = []; # list of paths in the app to make writeable 5 | pkgPath = "_writeable"; # path to create in read-only package that stores original content of writeable paths 6 | sysPath = required "writeable.sysPath" ''system path to store writeable data (e.g. "/var/lib/phpfpm/my-app")''; 7 | owner = required "writeable.owner" "user which owns the writeable files"; 8 | }; 9 | in 10 | { callPackage 11 | , lib 12 | , runCommand 13 | , writeScript 14 | , writeText 15 | 16 | , appConfig 17 | , writeable ? writeableDefault 18 | , ... 19 | }: 20 | let 21 | # Merge the given writeable settings with the defaults. 22 | writeable_ = writeableDefault // writeable; 23 | 24 | # We only care about writeble paths if the app is mostly frozen. 25 | writeablePaths = lib.optionals appConfig.freezeWordPress ( 26 | ["wp-content/uploads"] 27 | ++ lib.optional (!appConfig.freezePlugins) "wp-content/plugins" 28 | ++ lib.optional (!appConfig.freezeThemes) "wp-content/themes" 29 | ++ writeable_.appPaths 30 | ); 31 | 32 | wordpress = callPackage appConfig.wordpress {}; 33 | plugins = callPackage appConfig.plugins {}; 34 | themes = callPackage appConfig.themes {}; 35 | 36 | # The wp-config.php file. 37 | wpConfigFile = writeText "wp-config.php" appConfig.wpConfig.rendered; 38 | 39 | # Generates a list of paths in bash that can be looped over. 40 | listOfPaths = lib.concatMapStringsSep " " (x: "'${x}'"); 41 | 42 | # Generates the bash command to install a path from source to destination. 43 | # If the install is `frozen`, then we simply symlink, otherwise we copy. 44 | installPath = isFrozen: from: to: 45 | (if isFrozen then "ln -s" else "cp -r") + " " + ''"${from}" "${to}"''; 46 | 47 | # The build script for the app. 48 | # This will install WordPress, the wp-config, plugins, and themes. 49 | # If any writeble paths were configured, this script will copy them to a another folder in the 50 | # package and set up symlinks in their place the given writeable path on the system. 51 | buildPackageAt = out: '' 52 | mkdir -p $(dirname "${out}") # Make parent directory. 53 | 54 | cp -r "${wordpress}" "${out}" 55 | chmod -R +w "${out}" 56 | 57 | ${installPath appConfig.freezeWordPress wpConfigFile "${out}/wp-config.php"} 58 | 59 | # Install themes. 60 | rm -r "${out}/wp-content/themes"/* # remove bundled themes 61 | ${lib.concatMapStringsSep "\n" (x: installPath appConfig.freezeThemes x "${out}/wp-content/themes/${x.name}") themes} 62 | 63 | # Install plugins. 64 | rm -r "${out}/wp-content/plugins"/* # remove bundled plugins 65 | ${lib.concatMapStringsSep "\n" (x: installPath appConfig.freezePlugins x "${out}/wp-content/plugins/${x.name}") plugins} 66 | 67 | # TODO: Support translations. 68 | 69 | ${lib.optionalString (writeablePaths != []) '' 70 | # Make symlinks to writeable directories. 71 | writeable_orig_dir="${out}/${writeable_.pkgPath}" 72 | mkdir -p "$writeable_orig_dir" 73 | 74 | for thing in ${listOfPaths writeablePaths}; do 75 | original_thing="$writeable_orig_dir/$thing" 76 | parent=$(dirname "$original_thing") 77 | mkdir -p "$parent" 78 | 79 | # Move any existing data to the frozen writeable dir or create empty directory there. 80 | mv "${out}/$thing" "$parent" || mkdir -p "$original_thing" 81 | 82 | ln -s "${writeable_.sysPath}/$thing" "${out}/$thing" 83 | done 84 | ''} 85 | ''; 86 | 87 | # Copy the original writeable contents of the package to a writeable dir. 88 | initWriteablePathsFor = package: '' 89 | mkdir -p "$out" 90 | writeable_orig_dir="${package}/${writeable_.pkgPath}" 91 | for thing in $( ls "$writeable_orig_dir" ); do 92 | cp -r "$writeable_orig_dir/$thing" "$out" 93 | done 94 | ''; 95 | 96 | # Takes an existing script and makes a initialization script that only runs if the output path 97 | # has not been built yet. 98 | mkInitScript = script: writeScript "init-writeable-paths" '' 99 | #!/bin/sh 100 | 101 | out="${writeable_.sysPath}" 102 | 103 | if [ ! -d "$out" ]; then 104 | 105 | ${script} 106 | 107 | chown -R "${writeable_.owner}" "$out" 108 | chmod -R 744 "$out" 109 | 110 | else 111 | echo Output directory already exists. Not building path: "$out" 112 | fi 113 | ''; 114 | 115 | in if appConfig.freezeWordPress 116 | then rec { 117 | # For a mostly frozen app, we install it as a package and set up writeable paths on first run. 118 | initScript = mkInitScript (initWriteablePathsFor package); 119 | package = runCommand "wordpress-app" { 120 | preferLocalBuild = true; 121 | } (buildPackageAt "$out"); 122 | } 123 | else rec { 124 | # For fully writeable app, we skip package installation and write the app directly to the 125 | # writeable path on first run. 126 | initScript = mkInitScript (buildPackageAt package); 127 | package = writeable_.sysPath; 128 | } 129 | -------------------------------------------------------------------------------- /server/logical.nix: -------------------------------------------------------------------------------- 1 | # Logical definition of our server 2 | 3 | with import ./common.nix; 4 | 5 | overrideFn: # a function of the form (self: super: { ... }) 6 | # to override defaults in default-app-config.nix 7 | let 8 | appConfig = (import ../default-app-config.nix).extend overrideFn; 9 | in { 10 | network = { 11 | inherit (appConfig) enableRollback description; 12 | }; 13 | 14 | ${machineName} = { config, pkgs, ... }: let 15 | # This is not being used but can be useful for testing/development: 16 | phpTestIndex = pkgs.writeTextDir "index.php" ""; 17 | 18 | defaultDbSetup = pkgs.writeText "default-db-setup.sql" '' 19 | SET NAMES ${appConfig.dbConfig.charset}; 20 | ''; 21 | 22 | acmeChallengesDir = "/var/www/challenges"; 23 | phpFpmListen = "/run/phpfpm/wordpress-pool.sock"; 24 | enablePageSpeed = pkgs.stdenv.isLinux && appConfig.googlePageSpeed.enable; 25 | 26 | writeableDataPath = "/var/lib/phpfpm/${appConfig.name}"; 27 | app = pkgs.callPackage ./app.nix { 28 | inherit appConfig; 29 | writeable = { 30 | sysPath = writeableDataPath; 31 | owner = config.services.nginx.user; 32 | }; 33 | }; 34 | 35 | nginxConfig = import ./nginx-config.nix { 36 | inherit config pkgs acmeChallengesDir phpFpmListen; 37 | inherit (appConfig) enableHttps host hostRedirects maxUploadMb; 38 | appRoot = "${app.package}"; 39 | dhParams = 40 | if appConfig.enableHttps 41 | then "${config.security.dhparams.path}/nginx.pem" 42 | else null; 43 | pageSpeedCachePath = 44 | if appConfig.googlePageSpeed.enable 45 | then appConfig.googlePageSpeed.cachePath 46 | else null; 47 | fastCgiCachePath = 48 | if appConfig.fastCgiCache.enable 49 | then appConfig.fastCgiCache.cachePath 50 | else null; 51 | extraLocations = appConfig.extraNginxLocations; 52 | }; 53 | 54 | phpIni = import ./php-config.nix { inherit pkgs config appConfig; }; 55 | 56 | httpsModule = { 57 | security.acme.certs.${appConfig.host} = { 58 | webroot = acmeChallengesDir; 59 | email = appConfig.adminEmail; 60 | extraDomains = pkgs.lib.genAttrs appConfig.hostRedirects (x: null); 61 | postRun = "systemctl reload nginx.service"; 62 | }; 63 | 64 | # Depending on hardware, first-time deploy could take a good 5-15 minutes for this to generate. 65 | security.dhparams.params = { nginx = 3072; }; 66 | }; 67 | 68 | in { 69 | 70 | imports = [ 71 | (if appConfig.enableHttps then httpsModule else {}) 72 | ] ++ appConfig.imports; 73 | 74 | networking = { 75 | hostName = machineName; 76 | firewall.allowedTCPPorts = [80] ++ pkgs.lib.optional appConfig.enableHttps 443; 77 | }; 78 | 79 | environment.systemPackages = with pkgs; [ 80 | gzip htop unzip nix-repl php vim wp-cli zip 81 | ]; 82 | 83 | time.timeZone = appConfig.timezone; 84 | 85 | services.nginx = { 86 | enable = true; 87 | package = pkgs.callPackage ./nginx.nix { inherit enablePageSpeed; }; 88 | httpConfig = nginxConfig; 89 | }; 90 | 91 | services.mysql = { 92 | enable = appConfig.dbConfig.isLocal; 93 | package = pkgs.mysql; # actually MariaDB 94 | 95 | initialDatabases = [ 96 | { 97 | name = appConfig.dbConfig.name; 98 | schema = defaultDbSetup; 99 | } 100 | ]; 101 | }; 102 | 103 | systemd.services.mysql.serviceConfig.Restart = "on-failure"; 104 | 105 | services.phpfpm = { 106 | phpOptions = phpIni; 107 | pools.wordpress-pool = import ./php-fpm-conf.nix { 108 | inherit pkgs config phpFpmListen; 109 | processSettings = appConfig.phpFpmProcessSettings; 110 | }; 111 | }; 112 | 113 | services.postfix.enable = true; 114 | 115 | systemd.services.init-writeable-paths = { 116 | description = "Initialize writeable directories for the app"; 117 | before = [ "phpfpm.service" ]; 118 | after = [ "network.target" ]; 119 | wantedBy = [ "multi-user.target" "phpfpm.service" "nginx.service" ]; 120 | serviceConfig = { 121 | Type = "oneshot"; 122 | ExecStart = app.initScript; 123 | }; 124 | }; 125 | 126 | systemd.services.install-wp = let 127 | deps = [ "init-writeable-paths.service" "mysql.service" ]; 128 | in { 129 | enable = appConfig.autoInstall.enable; 130 | description = "Configure WordPress installation with WP-CLI"; 131 | before = [ "nginx.service" ]; 132 | after = deps; 133 | wants = deps; 134 | wantedBy = [ "multi-user.target" ]; 135 | serviceConfig = { 136 | Type = "oneshot"; 137 | ExecStart = import ./install-wp.nix { 138 | inherit pkgs config appConfig writeableDataPath; 139 | appPackage = app.package; 140 | }; 141 | }; 142 | environment.PHP_INI_SCAN_DIR = let 143 | customIni = pkgs.writeTextDir "wp-cli-custom.ini" phpIni; 144 | in "${pkgs.php}/etc:${customIni}"; 145 | }; 146 | }; 147 | } 148 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Nix](https://nixos.org/nix/) Setup for [Wordpress CMS](https://wordpress.org/) 2 | 3 | This repository contains everything necessary to test and deploy fully operational web servers for [Wordpress CMS](https://wordpress.com/) sites. 4 | 5 | This setup uses the powerful [Nix](https://nixos.org/nix/) package management system and its accompanying toolset: 6 | 7 | - [NixOps](https://nixos.org/nixops/) for deployments 8 | - [NixOS](https://nixos.org/) as the Linux-based server OS 9 | 10 | **Note:** Nix does not support Windows. If you're on Windows, you'll need to run this from within a Virtual Machine (VM). 11 | 12 | With this setup, you can easily deploy your site to one or more servers with minimal effort. You can (and should) also deploy to local [VirtualBox](https://www.virtualbox.org/) virtual machines. And, you can even use the Nix packages to install the site directly on your local host. 13 | 14 | ## Features 15 | 16 | * Automatically builds a working server with Nginx, PHP-FPM, MySQL, and WordPress. 17 | * Automatically configures TLS/SSL using [Let's Encrypt](https://letsencrypt.org/). 18 | * Configures PHP OpCache and a WordPress plugin to manage it. 19 | * Configures Nginx Fastcgi-cache with a cache-purging module and a WordPress plugin to manage it. 20 | * Installs and configures Google's PageSpeed Nginx module. 21 | * Allows WordPress configuration (settings, versions, plugins, themes, etc.) to be managed entirely by Nix. This means: 22 | * Upgrades and changes can be tracked in version control. 23 | * Deployments are reproducible for testing (e.g. in VirtualBox or on a staging server). 24 | * Security is enhanced by having most PHP files read-only. 25 | * Highly configurable: most of these settings can be tweaked easily. 26 | 27 | 28 | ## Requirements 29 | 30 | 1. First install [Nix](https://nixos.org/nix/). It is not invasive and can be removed easily if you change your mind (using `rm -r /nix`). 31 | 2. Deployments are done with [NixOps](https://nixos.org/nixops/). You can install `nixops` with `nix` by running `nix-env -i nixops`. However, you don't need to because this repository has a `deploy/manage` script that you'll use which will run `nixops` tasks for you. 32 | 3. Install [VirtualBox](https://www.virtualbox.org/) in order to test your server deployments. 33 | 4. If you plan to deploy to a real server, you will likely need to keep secrets in this repository. That will require installing [git-crypt](https://www.agwa.name/projects/git-crypt/) and setting it up. See `SETUP-SECRETS.md` for information on that. 34 | 35 | ### Attention macOS Users! 36 | 37 | This project requires that you build Linux binaries which can be deployed to a server (VirtualBox or otherwise). Since macOS cannot natively build Linux binaries, you will need a NixOS build slave running. 38 | 39 | 1. Install [Docker](https://www.docker.com/) and then use [this script](https://github.com/LnL7/nix-docker/blob/master/start-docker-nix-build-slave) to set up a NixOS build slave. For example: 40 | * `source <(curl -fsSL https://raw.githubusercontent.com/LnL7/nix-docker/master/start-docker-nix-build-slave)` 41 | * `deploy/manage vbox deploy` (or some other deployment command) 42 | 2. If you can't/don't want to install Docker, you can use NixOps to create a NixOS build slave via VirtualBox using [this](https://github.com/3noch/nix-vbox-build-slave). Note that using Docker is almost certainly going to be easier so I recommend that way instead. 43 | 44 | 45 | ## Setting Up WordPress 46 | 47 | 1. Create unique WordPress keys for your site (must be in the same directory as `default-app-config.nix`): 48 | * `curl https://api.wordpress.org/secret-key/1.1/salt/ > wordpress-keys.php.secret`. 49 | 2. Configure your site by editing `default-app-config.nix`. 50 | * For automatic install using WP-CLI: 51 | * Configure the `autoInstall` section to use `enable = true;`. 52 | * Copy `./wordpress-admin.keys.nix.sample` to `./wordpress-admin.keys.nix` and replace `...` with your credentials. 53 | * For a traditional install where WordPress is entirely managed by the admin panel, use `freezeWordPress = false;`. 54 | * To have Nix manage themes but not plugins, you can use `freezeWordPress = true; freezeThemes = true; freezePlugins = false;`. 55 | * When WordPress is frozen (i.e. managed by Nix), use `wordpress.nix` to govern the installed version. 56 | * When plugins are frozen (i.e. managed by Nix), use `plugins.nix` to govern which plugins are installed. 57 | * When themes are frozen (i.e. managed by Nix), use `themes.nix` to govern which themes are installed. 58 | 3. More complex settings can be managed in `server/`. 59 | * For example, change PHP-FPM configuration in `server/php-fpm-config.nix`. 60 | 61 | 62 | ## Deploying to VirtualBox 63 | 64 | Create a VirtualBox deployment: 65 | 66 | 1. `deploy/manage vbox create '' ''` 67 | 2. `deploy/manage vbox deploy` 68 | 69 | **Notes:** 70 | 71 | * `nixops` deployments can sometimes be finicky. If something hangs or fails, try running it again. It is a very deterministic system so this should not be a problem. 72 | * Run `deploy/manage --help` to see all options (this is just `nixops` underneath). 73 | 74 | You should then be able to open the IP of the VM in your browser and test it. If you don't know the IP, run `deploy/manage vbox info`. 75 | 76 | 77 | ### Troubleshooting 78 | 79 | * If you're on macOS (Darwin), be sure you have a NixOS build slave set up as described above. 80 | * If the state of your VirtualBox VM changes in a way that `nixops` didn't notice, your deployments may fail. Try running `deploy/manage deploy -d vbox --check` (using the `--check` flag) to tell `nixops` to reassess the state of the machine. 81 | * Sometimes VirtualBox will give your machine a new IP. If this happens, `nixops` (i.e. the `manage` script) may fail to connect to your machine via SSH. If this happens, remove the line with the old IP from your `~/.ssh/known_hosts` file and try again with the `--check` flag. 82 | * Sometimes `nixops` will fail to deploy because a VirtualBox disk from a previous deploy is still registered. To fix this, take the given disk UUID and run `VBoxManage closemedium disk --delete`. 83 | 84 | 85 | ## Deploying to Real Servers 86 | 87 | With this setup you can deploy to any PaaS/IaaS service supported by `nixops`. Right now this repository contains prewritten configurations for 88 | 89 | * Google Cloud Compute's [Google Compute Engine (GCE)](https://cloud.google.com/compute/) - see `DEPLOY-GCE.md`. 90 | * [DigitalOcean](https://www.digitalocean.com/) - see `DEPLOY-DIGITAL-OCEAN.md`. 91 | 92 | We plan to add more (such as AWS) in the future. If you want to do it yourself and understand Nix, the work to add this configuration is minimal. Pull requests welcome! 93 | 94 | **NOTE:** When SSL/TLS is enabled for production servers, the first deployment may take a *long time* (i.e. more than 20 minutes) to finish. A large chunk of first-deployment time will be spent generating new [DH parameters](https://en.wikipedia.org/wiki/Diffie%E2%80%93Hellman_key_exchange) for Nginx. This is normal! 95 | 96 | ## Keeping Secrets 97 | 98 | This repository setup assumes you want to keep some things a secret. See `SETUP-SECRETS.md` for a rundown of how that works. 99 | 100 | ## Upgrading Nixpkgs 101 | 102 | All dependencies are fixed to a specific version of Nixpkgs which is configured in `deploy/nixpkgs-version.nix` which contains instructions for upgrading. The nixpkgs version also governs the version of NixOps to use during deployments. This can be overridden in `nixpkgs-version.sh`. 103 | 104 | 105 | ## Acknowledgements 106 | 107 | * The server setup is highly influenced by 108 | * https://github.com/nystudio107/nginx-craft 109 | * https://www.nginx.com/blog/9-tips-for-improving-wordpress-performance-with-nginx/ 110 | * https://easyengine.io/wordpress-nginx/tutorials/single-site/fastcgi-cache-with-purging/ 111 | * Special thanks to @khalwat 112 | -------------------------------------------------------------------------------- /server/nginx-config.nix: -------------------------------------------------------------------------------- 1 | # References: 2 | # https://www.nginx.com/resources/wiki/start/topics/examples/phpfcgi/ 3 | # https://www.nginx.com/blog/9-tips-for-improving-wordpress-performance-with-nginx/ 4 | # https://easyengine.io/wordpress-nginx/tutorials/single-site/fastcgi-cache-with-purging/ 5 | 6 | { config # machine configuration 7 | , pkgs 8 | 9 | , host # host for this site 10 | , hostRedirects ? [] # list of hosts that redirect to the primary host 11 | , appRoot # root directory to serve 12 | , enableHttps # serve the site over HTTPS only? 13 | , dhParams ? null # path to the dhparams pem file to use for TLS 14 | , maxUploadMb # maximum upload size in MB 15 | , fastCgiCachePath # path to fast CGI cache directory or `null` to disable the cache 16 | , pageSpeedCachePath # path to PageSpeed cache directory or `null` disable PageSpeed 17 | 18 | , acmeChallengesDir # directory where ACME (Let's Encrypt) challenges are stored 19 | , phpFpmListen # listen setting for PHP-FPM 20 | 21 | , extraLocations # extra location directives to place above WordPress 22 | }: 23 | let 24 | 25 | isGiven = x: !(isNull x || x == ""); 26 | enableFastCgiCache = isGiven fastCgiCachePath; 27 | enablePageSpeed = isGiven pageSpeedCachePath; 28 | 29 | rootUrl = (if enableHttps then "https" else "http") + "://" + host; 30 | 31 | fullNginxConfig = '' 32 | ${if enableFastCgiCache then fastgciCachePart.cacheConfig else ""} 33 | ${if enableHttps then secureConfig else insecureConfig} 34 | ''; 35 | 36 | secureConfig = '' 37 | server { 38 | server_name ${host} ${pkgs.lib.concatStringsSep " " hostRedirects}; 39 | ${listenPart.insecure} 40 | 41 | location /.well-known/acme-challenge { 42 | root "${acmeChallengesDir}"; 43 | } 44 | 45 | location / { 46 | return 301 https://${host}$request_uri; 47 | } 48 | } 49 | 50 | ${pkgs.lib.optionalString (hostRedirects != []) '' 51 | server { 52 | server_name ${pkgs.lib.concatStringsSep " " hostRedirects}; 53 | ${listenPart.secure} 54 | 55 | ${tlsPart} 56 | 57 | return 301 https://${host}$request_uri; 58 | } 59 | ''} 60 | 61 | server { 62 | server_name ${host}; 63 | ${listenPart.secure} 64 | 65 | ${tlsPart} 66 | ${serverPart} 67 | } 68 | ''; 69 | 70 | insecureConfig = '' 71 | server { 72 | server_name ${host}; 73 | ${listenPart.insecure} 74 | 75 | ${serverPart} 76 | } 77 | 78 | ${pkgs.lib.optionalString (hostRedirects != []) '' 79 | server { 80 | server_name ${pkgs.lib.concatStringsSep " " hostRedirects}; 81 | ${listenPart.insecure} 82 | return 301 http://${host}$request_uri; 83 | } 84 | ''} 85 | ''; 86 | 87 | # Listen for both IPv4 & IPv6 requests with http2 enabled 88 | listenPart = { 89 | secure = '' 90 | listen 443 ssl http2; 91 | listen [::]:443 ssl http2; 92 | ''; 93 | 94 | insecure = '' 95 | listen 80; 96 | listen [::]:80; 97 | ''; 98 | }; 99 | 100 | tlsPart = '' 101 | # SSL/TLS configuration, with TLS1 disabled 102 | ssl_certificate ${config.security.acme.directory}/${host}/fullchain.pem; 103 | ssl_certificate_key ${config.security.acme.directory}/${host}/key.pem; 104 | ssl_protocols TLSv1.2 TLSv1.1; 105 | ssl_prefer_server_ciphers on; 106 | ssl_ciphers "EECDH+ECDSA+AESGCM EECDH+aRSA+AESGCM EECDH+ECDSA+SHA384 EECDH+ECDSA+SHA256 EECDH+aRSA+SHA384 EECDH+aRSA+SHA256 EECDH+aRSA+RC4 EECDH EDH+aRSA RC4 !aNULL !eNULL !LOW !3DES !MD5 !EXP !PSK !SRP !DSS !RC4"; 107 | ssl_session_timeout 30m; 108 | ssl_session_cache shared:SSL:50m; 109 | ssl_stapling on; 110 | ssl_stapling_verify on; 111 | 112 | ${pkgs.lib.optionalString (!(isNull dhParams)) '' 113 | ssl_dhparam "${dhParams}"; 114 | ''} 115 | ''; 116 | 117 | serverPart = '' 118 | root "${appRoot}"; 119 | index index.html index.htm index.php; 120 | charset utf-8; 121 | 122 | # 301 Redirect URLs with trailing /'s as per https://webmasters.googleblog.com/2010/04/to-slash-or-not-to-slash.html 123 | #rewrite ^/(.*)/$ /$1 permanent; 124 | 125 | # Change // -> / for all URLs, so it works for our php location block, too 126 | merge_slashes off; 127 | rewrite (.*)//+(.*) $1/$2 permanent; 128 | 129 | # Access and error logging 130 | #access_log off; 131 | #error_log /var/log/nginx/SOMEDOMAIN.com-error.log error; 132 | # If you want error logging to go to SYSLOG (for services like Papertrailapp.com), uncomment the following: 133 | #error_log syslog:server=unix:/dev/log,facility=local7,tag=nginx,severity=error; 134 | 135 | # Don't send the nginx version number in error pages and Server header 136 | server_tokens off; 137 | 138 | # Load configuration files from nginx-partials 139 | include ${./nginx-partials}/*.conf; 140 | 141 | # Extra locations (if any) 142 | ${pkgs.lib.concatStringsSep "\n" extraLocations} 143 | 144 | # Root directory location handler 145 | location / { 146 | try_files $uri/index.html $uri $uri/ /index.php?$query_string; 147 | } 148 | 149 | ${if enableFastCgiCache then fastgciCachePart.serverConfig else ""} 150 | 151 | # php-fpm configuration 152 | location ~ [^/]\.php(/|$) { 153 | fastcgi_split_path_info ^(.+\.php)(/.+)$; 154 | fastcgi_pass unix:${phpFpmListen}; 155 | fastcgi_index index.php; 156 | 157 | include "${config.services.nginx.package}/conf/fastcgi.conf"; 158 | fastcgi_param PATH_INFO $fastcgi_path_info; 159 | fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info; 160 | 161 | # Mitigate https://httpoxy.org/ vulnerabilities 162 | fastcgi_param HTTP_PROXY ""; 163 | 164 | fastcgi_intercept_errors off; 165 | fastcgi_buffer_size 16k; 166 | fastcgi_buffers 4 16k; 167 | fastcgi_connect_timeout 300; 168 | fastcgi_send_timeout 300; 169 | fastcgi_read_timeout 300; 170 | 171 | ${if enableFastCgiCache then fastgciCachePart.phpCacheConfig else ""} 172 | } 173 | 174 | ${if enablePageSpeed then pageSpeedPart else ""} 175 | 176 | # Misc settings 177 | sendfile off; 178 | client_max_body_size ${toString maxUploadMb}m; 179 | ''; 180 | 181 | fastgciCachePart = let 182 | cacheKeyPrefix = "$scheme$request_method$http_host"; 183 | in { 184 | cacheConfig = '' 185 | # FastCGI Cache Settings 186 | fastcgi_cache_path "${fastCgiCachePath}" levels=1:2 keys_zone=WORDPRESS:100m inactive=60m; 187 | fastcgi_cache_key "${cacheKeyPrefix}$request_uri"; 188 | fastcgi_cache_use_stale error timeout invalid_header http_500; 189 | ''; 190 | 191 | serverConfig = '' 192 | # Configure FastCGI Cache 193 | set $skip_cache 0; 194 | 195 | # POST requests and URLs with a query string should always go to PHP 196 | if ($request_method = POST) { 197 | set $skip_cache 1; 198 | } 199 | if ($query_string != "") { 200 | set $skip_cache 1; 201 | } 202 | 203 | # Don't cache URIs containing the following segments. 204 | if ($request_uri ~* "/wp-admin/|/xmlrpc.php|wp-.*.php|/feed/|index.php|sitemap(_index)?.xml") { 205 | set $skip_cache 1; 206 | } 207 | 208 | # Don't use the cache for logged in users or recent commenters. 209 | # https://codex.wordpress.org/WordPress_Cookies 210 | if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in") { 211 | set $skip_cache 1; 212 | } 213 | 214 | location ~ /purge(/.*) { 215 | fastcgi_cache_purge WORDPRESS "${cacheKeyPrefix}$1"; 216 | } 217 | ''; 218 | 219 | phpCacheConfig = '' 220 | # For testing the caching mechanism. 221 | add_header X-FastCGI-Cache $upstream_cache_status; 222 | 223 | fastcgi_cache_bypass $skip_cache; 224 | fastcgi_no_cache $skip_cache; 225 | 226 | fastcgi_cache WORDPRESS; 227 | fastcgi_cache_valid 60m; 228 | ''; 229 | }; 230 | 231 | pageSpeedPart = '' 232 | # PageSpeed configuration 233 | pagespeed on; 234 | pagespeed FileCachePath "${pageSpeedCachePath}"; 235 | pagespeed LowercaseHtmlNames on; 236 | pagespeed RewriteLevel CoreFilters; 237 | pagespeed EnableFilters move_css_to_head,prioritize_critical_css,remove_comments,collapse_whitespace,trim_urls; 238 | pagespeed LoadFromFile "${rootUrl}/wp-content/" "${appRoot}/wp-content/"; 239 | 240 | #pagespeed Statistics on; 241 | #pagespeed StatisticsLogging on; 242 | #pagespeed LogDir /var/log/nginx-pagespeed; 243 | #pagespeed AdminPath /pagespeed-admin; 244 | 245 | # Admin related blocks must preceed the other blocks. 246 | #location ~ "^/pagespeed-admin" { 247 | # allow all; 248 | #} 249 | 250 | # Ensure requests for pagespeed optimized resources go to the pagespeed handler 251 | # and no extraneous headers get set. 252 | location ~ "\.pagespeed\.([a-z]\.)?[a-z]{2}\.[^.]{10}\.[^.]+" { 253 | add_header "" ""; 254 | } 255 | location ~ "^/pagespeed_static/" { } 256 | location ~ "^/ngx_pagespeed_beacon$" { } 257 | ''; 258 | 259 | in fullNginxConfig 260 | --------------------------------------------------------------------------------