<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>database Archives - Alexandros Georgiou</title>
	<atom:link href="https://www.alexgeorgiou.gr/tag/database/feed/" rel="self" type="application/rss+xml" />
	<link>https://www.alexgeorgiou.gr/tag/database/</link>
	<description>Balancing brackets for a living</description>
	<lastBuildDate>Wed, 29 May 2024 11:29:52 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	

<image>
	<url>https://www.alexgeorgiou.gr/wp-content/uploads/2021/07/cropped-alexgeorgiou-icon-32x32.png</url>
	<title>database Archives - Alexandros Georgiou</title>
	<link>https://www.alexgeorgiou.gr/tag/database/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>📦 Two dockerized WordPress sites, with Let&#8217;s Encrypt, logging, SMTP relay, controlled by a systemd service, and daily backups</title>
		<link>https://www.alexgeorgiou.gr/two-dockerized-wordpress-sites/</link>
					<comments>https://www.alexgeorgiou.gr/two-dockerized-wordpress-sites/#respond</comments>
		
		<dc:creator><![CDATA[alexg]]></dc:creator>
		<pubDate>Wed, 20 Dec 2023 10:24:43 +0000</pubDate>
				<category><![CDATA[Blog]]></category>
		<category><![CDATA[backup]]></category>
		<category><![CDATA[compose]]></category>
		<category><![CDATA[cron]]></category>
		<category><![CDATA[database]]></category>
		<category><![CDATA[digitalocean]]></category>
		<category><![CDATA[DNS]]></category>
		<category><![CDATA[docker]]></category>
		<category><![CDATA[letsencrypt]]></category>
		<category><![CDATA[mysqldump]]></category>
		<category><![CDATA[nginx]]></category>
		<category><![CDATA[reverse proxy]]></category>
		<category><![CDATA[SMTP]]></category>
		<category><![CDATA[SQL]]></category>
		<category><![CDATA[wordpress]]></category>
		<guid isPermaLink="false">https://www.alexgeorgiou.gr/?p=1311</guid>

					<description><![CDATA[<p>Or, How I learned to stop worrying and love docker compose.</p>
<p>The post <a href="https://www.alexgeorgiou.gr/two-dockerized-wordpress-sites/">📦 Two dockerized WordPress sites, with Let&#8217;s Encrypt, logging, SMTP relay, controlled by a systemd service, and daily backups</a> appeared first on <a href="https://www.alexgeorgiou.gr">Alexandros Georgiou</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p>In this article I&#8217;m going to talk about how I set up two WordPress sites on one server. None of the articles I could come up with covered all the topics I was interested in. Not exactly groundbreaking, in fact it sounds simple. But the devil is in the details. To actually perform such a setup for the first time is actually pretty daunting. From setting up the DNS records to getting file permissions to work, to getting the reverse proxy right, it&#8217;s all a complicated mess that I&#8217;m going to delineate for you (and me) here, while it&#8217;s still fresh in my head.</p>



<h1 class="wp-block-heading">Features</h1>



<ul class="wp-block-list">
<li>Two WordPress sites: <code>https://www.example1.com</code> and <code>https://www.example2.com</code>.</li>



<li>Redirects from <code>https://example1.com</code>, <code>http://example1.com</code>, <code>http://www.example1.com</code> to <code>https://example1.com</code> (and the same for <code>example2.com</code>).</li>



<li>Let&#8217;s encrypt certificates.</li>



<li>WordPress debug logging with logrotate. I have ranted previously about <a href="https://www.alexgeorgiou.gr/wordpress-production-to-log-or-not-to-log/">why I think having debug logging turned on is important on live sites</a>.</li>



<li>Emails must work in WordPress.</li>



<li>The containers must run as a service, so that they start with system start, and exit gracefully on system shutdown.</li>



<li>Daily backups of all WordPress files and MySQL databases.</li>



<li>Networks of the two sites should be isolated for security.</li>
</ul>



<h1 class="wp-block-heading">Shameless plug of my referral links</h1>



<p>We start with a hosted server. This can be a dedicated server or a server slice. Hosting providers that I like are:</p>



<ul class="wp-block-list">
<li><a href="https://www.digitalocean.com/?refcode=44d4d2184573">DigitalOcean</a> &#8211; Using this link you get a $200, 60-day credit to try their products. If you spend $25 after your credit expires, I will get $25 in credit.</li>



<li><a href="https://hostinger.com/?REFERRALCODE=1ALEXANDROS15">Hostinger</a> &#8211; You don&#8217;t get anything with this link, except for a great hosting service. Again, I get a commission from this link if you stick with Hostinger for 45 days. Think of it as my reward for writing such a great article for you.</li>
</ul>



<p>I have a Debian droplet on DigitalOcean with 2GB of RAM, but with some tweaking it&#8217;s possible to squeeze two low-traffic WordPress sites in 1GB, if you really need to keep the monthly costs down.</p>



<h1 class="wp-block-heading">First let&#8217;s get the (DNS) record straight</h1>



<p>The first order of business is to setup the DNS records. We&#8217;re going to need two <code>A</code> records to point to our server&#8217;s IP, and two <code>CNAME</code> records that will be <code>wwww.</code> aliases of the bare domain. Oh, and we&#8217;ll need some <code>NS</code> records to point to the domain name provider (in this case Digital Ocean).</p>



<figure class="wp-block-table"><table><thead><tr><th>Type</th><th>Hostname</th><th>Value</th><th>TTL</th></tr></thead><tbody><tr><td><code>A</code></td><td><code>example1.com</code></td><td>(my server&#8217;s IP)</td><td>1800</td></tr><tr><td><code>A</code></td><td><code>example2.com</code></td><td>(my server&#8217;s IP)</td><td>1800</td></tr><tr><td><code>CNAME</code></td><td><code>www.example1.com</code></td><td>alias of <code>example1.com.</code></td><td>1800</td></tr><tr><td><code>CNAME</code></td><td><code>www.example2.com</code></td><td>alias of <code>example2.com.</code></td><td>1800</td></tr><tr><td><code>NS</code></td><td><code>example1.com</code></td><td><code>ns1.digitalocean.com</code></td><td>14400</td></tr><tr><td><code>NS</code></td><td><code>example1.com</code></td><td><code>ns2.digitalocean.com</code></td><td>14400</td></tr><tr><td><code>NS</code></td><td><code>example1.com</code></td><td><code>ns3.digitalocean.com</code></td><td>14400</td></tr><tr><td><code>NS</code></td><td><code>example2.com</code></td><td><code>ns1.digitalocean.com</code></td><td>14400</td></tr><tr><td><code>NS</code></td><td><code>example2.com</code></td><td><code>ns2.digitalocean.com</code></td><td>14400</td></tr><tr><td><code>NS</code></td><td><code>example2.com</code></td><td><code>ns3.digitalocean.com</code></td><td>14400</td></tr></tbody></table></figure>



<p>I like to keep the TTL (Time-To-Live) values low until I&#8217;m finished with my setup. I&#8217;ve set everything to <code>1800</code> seconds which is half an hour. Once I&#8217;m sure that everything is OK, I can increase the values to something larger like <code>14400</code> (four hours).</p>



<h1 class="wp-block-heading">ssh</h1>



<p>We are going to need to be able to login to the server with a passwordless setup.</p>



<p>Login as root to the new server via the admin console.</p>



<p>Create a regular user with <code>adduser</code>:</p>



<pre class="wp-block-code"><code><code>a<span style="background-color: initial; font-family: inherit; font-size: inherit; color: initial;">dduser yourusername</span></code></code></pre>



<p>Then add the user to sudoers with:</p>



<pre class="wp-block-code"><code><code>usermod -aG sudo yourusername</code></code></pre>



<p>(Replace <code>yourusername</code> with your username.)</p>



<p>Once we are on our local machine, we check if we already have an ssh key with:</p>



<pre class="wp-block-code"><code><code>ls -al ~/.ssh/id_*.pub</code></code></pre>



<p>If we don&#8217;t have any, we can generate one with:</p>



<pre class="wp-block-code"><code><code>ssh-keygen -t rsa -b 4096 -C "your_email@domain.com"</code></code></pre>



<p>Once we are sure that there is a key, we upload it to the new server with:</p>



<pre class="wp-block-code"><code><code>ssh-copy-id yourusername@server_ip_address</code></code></pre>



<p>(Again replace <code>yourusername</code> with your remote username, and <code>server_ip_address</code> with your ip address. You will need to enter the password you entered in <code>adduser</code>.)</p>



<h1 class="wp-block-heading">Docker compose</h1>



<p>First, let&#8217;s install docker on the server by following the <a href="https://docs.docker.com/engine/install/debian/" target="_blank" rel="noreferrer noopener">installation instructions for Debian</a>. I am not going to repeat the instructions here. If you have chosen a different distro, follow the respective instructions.</p>



<p>We are going to create a <code>docker-compose.yml</code> file. This file describes how the different docker containers are orchestrated.</p>



<p>We are going to need four containers:</p>



<ul class="wp-block-list">
<li>Two databases for the two sites.</li>



<li>Two WordPress installations.</li>
</ul>



<p>I&#8217;m first going to show some simple compose configs with the basics, then we are going to add the bells and whistles. Here goes:</p>



<h2 class="wp-block-heading">Two databases, sitting in a server</h2>



<pre class="wp-block-code"><code>version: "3.8"

name: droplet

networks:
    net1:
    net2:

volumes:
  db1volume:
  db2volume:

services:

  db1:
    image: mysql:8.2.0
    networks:
      - net1
    restart: unless-stopped
    expose:
      - "3306"
    volumes:
      - db1volume:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: wp1_root_pass
      MYSQL_DATABASE: wp_db1
      MYSQL_USER: db1_user
      MYSQL_PASSWORD: db1_pass

  db2:
    image: mysql:8.2.0
    networks:
      - net2
    restart: unless-stopped
    expose:
      - "3306"
    volumes:
      - db2volume:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: wp2_root_pass
      MYSQL_DATABASE: wp_db2
      MYSQL_USER: db2_user
      MYSQL_PASSWORD: db2_pass</code></pre>



<p>There&#8217;s already a lot going on here:</p>



<ul class="wp-block-list">
<li>We are defining our composition to have a name. Here I am using <code>droplet</code>. This will also be the prefix for the names of all the containers.</li>



<li>We are defining two networks, <code>net1</code> and <code>net2</code>. Only containers on the same network can talk to each other. We don&#8217;t want our <code>example1.com</code> WordPress to have any access to the MySQL database of <code>example2.com</code>.</li>



<li>Next we are defining two identical <code>mysql:8.2.0</code> containers, named <code>db1</code> and <code>db2</code>.</li>



<li>Each of the two databases is put in its respective network (<code>net1</code> and <code>net2</code>).</li>



<li>We want a database that has crashed to restart, unless we explicitly stop it.</li>



<li>We are going to let the databases listen to TCP port <code>3306</code>. This is the port where WordPress will connect. All other ports are firewalled.</li>



<li>We are going to mount the <code>/var/lib/mysql</code> directories into docker volumes named <code>db1volume</code> and <code>db2volume</code>.</li>



<li>Next we are going to use some environment variables that the startup script inside the mysql image recognizes. These will set up a root password, a new empty database, and a username/password pair that WordPress will use to access this new database. The startup script will do all the <code>CREATE DATABASE</code>, <code>CREATE USER</code> and <code>GRANT</code> magic for us. You can learn more about the MySQL docker image <a href="https://dev.mysql.com/doc/mysql-installation-excerpt/8.2/en/docker-mysql-more-topics.html">here</a>.</li>
</ul>



<h2 class="wp-block-heading">A tale of two WordPresses</h2>



<p>Next, let&#8217;s also add the two WordPress services (these also go under the services section along with the databases):</p>



<pre class="wp-block-code"><code>  wp1:
    image: wordpress:latest
    networks:
      - net1
    depends_on:
      - db1
    user: 1000:1000
    restart: unless-stopped
    expose:
      - "80"
    volumes:
      - ./wp1fs:/var/www/html
    ports:
      - "127.0.0.1:8101:80"
    environment:
      WORDPRESS_DB_HOST: db1:3306
      WORDPRESS_DB_NAME: wp_db1
      WORDPRESS_DB_USER: db1_user
      WORDPRESS_DB_PASSWORD: db1_pass
      WORDPRESS_DEBUG: true

  wp2:
    image: wordpress:latest
    networks:
      - net2
    depends_on:
      - db1
    restart: unless-stopped
    expose:
      - "80"
    volumes:
      - ./wp2fs:/var/www/html
    ports:
      - "127.0.0.1:8102:80"
    environment:
      WORDPRESS_DB_HOST: db2:3306
      WORDPRESS_DB_NAME: wp_db2
      WORDPRESS_DB_USER: db2_user
      WORDPRESS_DB_PASSWORD: db2_pass
      WORDPRESS_DEBUG: true</code></pre>



<ul class="wp-block-list">
<li>We have named the two WordPress containers <code>wp1</code> and <code>wp2</code> and assigned them to our two networks, <code>net1</code> and <code>net2</code>.</li>



<li>We have defined that these <em>depend</em> on their respective databases to function.</li>



<li>We have defined that these containers are to be <em>restarted</em> if they crash, but not if we explicitly stop them.</li>



<li>We are exposing only HTTP port <code>80</code> to the networks. All other ports are firewalled. We are not exposing port <code>443</code> here. TLS encryption will be done at the host level that will run the reverse proxy (see below).</li>



<li>We are mounting two local directories here <code>./wp1fs</code> and <code>./wp2fs</code>. These will contain the WordPress installations. The first time that the containers run, WordPress will be installed in them. A special <code>wp-config.php</code> file will be placed in there. This file pulls the DB connection settings from the environment variables that we specify below.</li>



<li>We are port-mapping the HTTP <code>80</code> ports to the host&#8217;s ports <code>8101</code> and <code>8102</code>. These are the ports that the reverse proxy will use. They are bound to the loopback network (<code>127.0.0.1</code>), and are therefore not exposed to the outside world. If we had used just <code>8101:80</code>, this would map port 80 of the container to port <code>8101</code> of the host on all network interfaces, including the one facing the outside world. This is not ideal. We only want access to our services through our reverse proxy.</li>



<li>The <code>WORDPRESS_*</code> environment variables are specific to this wordpress image. We specify the databases, the login credentials that we also specified above, and we turn on debug logging. To learn more about these environment variables, click <a href="https://github.com/docker/awesome-compose/tree/master/official-documentation-samples/wordpress/" target="_blank" rel="noreferrer noopener">here</a>.</li>
</ul>



<p><em>NOTE: I have made the decision here to put the databases into system volumes (these live usually in <code>/var/lib/docker/volumes</code> and can be shared between containers, the WordPress filesystems are mounted in local directories which I call <code>wp1volume</code> and <code>wp2volume</code>. If you prefer to have all volumes unde <code>/var/lib</code>, you can delete the <code>./</code> prefix in front of the volume names.</em></p>



<h2 class="wp-block-heading">The bells and whistles</h2>



<p>If you thought that&#8217;s enough, <strong>you are gravely mistaken</strong>. Here&#8217;s a few more things to take care of:</p>



<h3 class="wp-block-heading">Database collation</h3>



<p>We are going to set the databases a UTF-8 multibyte collation for unicode support. Under the environment variables in the database services, we are going to add an explicit mysqld command:</p>



<pre class="wp-block-code"><code>command: "mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci</code></pre>



<p>And under the WordPress services, we are going to add the following environment variable:</p>



<pre class="wp-block-code"><code>  WORDPRESS_DB_COLLATE: utf8mb4_unicode_ci</code></pre>



<h2 class="wp-block-heading">File permissions</h2>



<p>If we run the above containers, WordPress won&#8217;t be able to install or remove any themes or plugins, and it won&#8217;t be able to do anything that requires writing to the file system.</p>



<p>This is because, in the WordPress images, the user that runs apache has a different uid and guid than the file system. The files are owned by <code>uid</code> <code>1000</code> and <code>guid</code> <code>1000</code>. We can specify that the user running stuff inside the container has the same numeric ids. To do this, we add the following to the two WordPress services:</p>



<pre class="wp-block-code"><code>user: 1000:1000</code></pre>



<h2 class="wp-block-heading">Database memory</h2>



<p>By default, a mysql instance will take up at least 360MB of memory once it&#8217;s running. Most of it is because of the Performance Schema instruments, which take up a lot of memory.</p>



<p>The Performance Schema is a database that keeps track of the mysqld server&#8217;s performance, and is useful for diagnostics. If you are not going to use this feature, then you can turn it off. The memory usage of each DB container will then fall to a little over 100MB.</p>



<p>We are going to create a file named <code>disable-perf-schema.cnf</code> with the following contents:</p>



<pre class="wp-block-code"><code>&#91;mysqld]
performance_schema = OFF</code></pre>



<p>This will be added to the mysql server&#8217;s config files. The server includes any <code>.cnf</code> files in the <code>/etc/mysql/conf.d</code> directory into its configuration. We can use the volumes section to map this file into our two db containers:</p>



<pre class="wp-block-code"><code>volumes:
  - db1:/var/lib/mysql
  - ./disable-perf-schema.cnf:/etc/mysql/conf.d/disable-perf-schema.cnf

volumes:
  - db2:/var/lib/mysql
  - ./disable-perf-schema.cnf:/etc/mysql/conf.d/disable-perf-schema.cnf</code></pre>



<p>There are more hacks to reduce the memory usage of mysqld, but these are beyond the scope of this article. For example, you can look into reducing the InnoDB buffer pool size.</p>



<h2 class="wp-block-heading">Log rotate</h2>



<p>We have enabled debug logging, because <a href="https://www.alexgeorgiou.gr/wordpress-production-to-log-or-not-to-log/">reasons</a>. This is cool, but the <code>/var/www/html/wp-content/debug.log</code> files will eventually fill up our containers if left unchecked. Enter <code>logrotate</code> to the rescue:</p>



<p>We are going to create a file named <code>wordpress.logrotate</code> with the following content:</p>



<pre class="wp-block-code"><code>/var/www/html/wp-content/debug.log
{
        su 1000 1000
        rotate 24
        copytruncate
        weekly
        missingok
        notifempty
        compress
}</code></pre>



<p>This will gzip old logs daily and will delete even older logs. If you are not sure about the details, ChatGPT and Bard can explain exactly what each line does.</p>



<p>Note how we use again the <code>uid</code> and <code>guid</code> of the WordPress image.</p>



<p>Let&#8217;s mount this file into our WordPress containers, by adding a line to their volume clause:</p>



<pre class="wp-block-code"><code>volumes:
  - ./wp1fs:/var/www/html
  - ./wordpress.logrotate:/etc/logrotate.d/wordpress

volumes:
  - ./wp2fs:/var/www/html
  - ./wordpress.logrotate:/etc/logrotate.d/wordpress</code></pre>



<h1 class="wp-block-heading">Docker compose recap</h1>



<p>We now have the following <code>docker-compose.yml</code> file:</p>



<pre class="wp-block-code"><code>version: "3.8"

name: droplet

networks:
    net1:
    net2:

volumes:
  db1volume:
  db2volume:

services:

  db1:
    image: mysql:8.2.0
    networks:
      - net1
    restart: unless-stopped
    expose:
      - "3306"
    volumes:
      - db1volume:/var/lib/mysql
      - ./disable-perf-schema.cnf:/etc/mysql/conf.d/disable-perf-schema.cnf
    environment:
      MYSQL_ROOT_PASSWORD: wp1_root_pass
      MYSQL_DATABASE: wp_db1
      MYSQL_USER: db1_user
      MYSQL_PASSWORD: db1_pass
    command: "mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --performance-schema-instrument='%=OFF' --innodb-buffer-pool-size=32M"

  db2:
    image: mysql:8.2.0
    networks:
      - net2
    restart: unless-stopped
    expose:
      - "3306"
    volumes:
      - db2volume:/var/lib/mysql
      - ./disable-perf-schema.cnf:/etc/mysql/conf.d/disable-perf-schema.cnf
    environment:
      MYSQL_ROOT_PASSWORD: wp2_root_pass
      MYSQL_DATABASE: wp_db2
      MYSQL_USER: db2_user
      MYSQL_PASSWORD: db2_pass
    command: "mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --performance-schema-instrument='%=OFF' --innodb-buffer-pool-size=32M"

  wp1:
    image: wordpress:latest
    networks:
      - net1
    depends_on:
      - db1
    user: 1000:1000
    restart: unless-stopped
    expose:
      - "80"
    volumes:
      - ./wp1fs:/var/www/html
      - ./wordpress.logrotate:/etc/logrotate.d/wordpress
    ports:
      - "8101:80"
    environment:
      WORDPRESS_DB_HOST: db1:3306
      WORDPRESS_DB_NAME: wp_db1
      WORDPRESS_DB_USER: db1_user
      WORDPRESS_DB_PASSWORD: db1_pass
      WORDPRESS_DB_COLLATE: utf8mb4_unicode_ci
      WORDPRESS_DEBUG: true

  wp2:
    image: wordpress:latest
    networks:
      - net2
    depends_on:
      - db1
    user: 1000:1000
    restart: unless-stopped
    expose:
      - "80"
    volumes:
      - ./wp2fs:/var/www/html
      - ./wordpress.logrotate:/etc/logrotate.d/wordpress
    ports:
      - "8102:80"
    environment:
      WORDPRESS_DB_HOST: db2:3306
      WORDPRESS_DB_NAME: wp_db2
      WORDPRESS_DB_USER: db2_user
      WORDPRESS_DB_PASSWORD: db2_pass
      WORDPRESS_DB_COLLATE: utf8mb4_unicode_ci
      WORDPRESS_DEBUG: true</code></pre>



<p>We can start this with <code>docker compose up</code> (we must first <code>cd</code> into the same directory as the <code>.yml</code> file).</p>



<p>We can see if it&#8217;s running with <code>docker compose ls</code>, and we can see the containers with <code>docker container ls</code>.</p>



<p>We can inspect memory usage with <code>docker stats</code>.</p>



<p>We can stop the containers with <code>docker compose down</code>.</p>



<p>If we also want to wipe the database volumes and start over, we can do <code>docker compose down -v</code> (DESTRUCTIVE!!!).</p>



<p>We can go into the shell of the first database with:</p>



<pre class="wp-block-code"><code>docker exec -it droplet-db1-1 bash</code></pre>



<p>And then, we can go into the mysql console with</p>



<pre class="wp-block-code"><code>mysql -u root -pwp1_root_pass</code></pre>



<p>We can go into the shell of the first WordPress with:</p>



<pre class="wp-block-code"><code>docker exec -it droplet-wp1-1 bash</code></pre>



<p>If we need to, we can install wp-cli using instructions from <a href="https://wp-cli.org/" target="_blank" rel="noreferrer noopener">https://wp-cli.org/</a>. The copy of <code>wp-cli</code> will not be persisted into the container across restarts. (Note: it&#8217;s possible to add special containers with <code>wp-cli</code> pre-installed, but again this is out of scope of this article. For more information, see the CLI images <a href="https://hub.docker.com/_/wordpress/">here</a>.</p>



<h1 class="wp-block-heading">DaaS (Docker-as-a-Service)</h1>



<p>We don&#8217;t want to have to issue <code>docker compose up</code> every time the server starts, and <code>docker compose down</code> every time the server stops. Let&#8217;s create a <code>systemd</code> unit, so that it runs as a service.</p>



<p>We&#8217;ll create a file named <code>/etc/systemd/system/docker-compose.service</code> with the following carefully crafted contents:</p>



<pre class="wp-block-code"><code>&#91;Unit]
Description=A bunch of containers
After=docker.service
Requires=docker.service

&#91;Service]
Type=oneshot
RemainAfterExit=yes
User=yourusername
ExecStart=/bin/bash -c "docker compose -f /home/yourusername/docker-compose.yml up --detach"
ExecStop=/bin/bash -c "docker compose -f /home/yourusername/docker-compose.yml stop"

&#91;Install]
WantedBy=multi-user.target</code></pre>



<ul class="wp-block-list">
<li>Replace <code>yourusername</code> with your username (duh!).</li>



<li>Replace the description with something less silly (optional).</li>



<li>Note how we only start this service <em>after</em> the docker service starts.</li>



<li>Note that we do a <code>--detach</code>. This will start the containers in the background and exit, without showing the logs of all the containers in the standard output.</li>
</ul>



<p>We can now start the service with</p>



<pre class="wp-block-code"><code>sudo service docker-compose up</code></pre>



<p>And stop it with</p>



<pre class="wp-block-code"><code>sudo service docker-compose down</code></pre>



<p>If we want to see the logs of all the containers, we can type</p>



<pre class="wp-block-code"><code>docker compose logs -f</code></pre>



<p>We should now be able to do <code>curl http://127.0.0.1:8101</code> and see the HTML of the front page of the first WordPress.</p>



<h1 class="wp-block-heading">The reverse proxy</h1>



<p>The database and WordPress containers are running, but they are not yet exposed to the outside world. To do this, we are going to use <code>nginx</code> as a reverse proxy.</p>



<p>The reverse proxy will:</p>



<ul class="wp-block-list">
<li>handle all the redirects that we need</li>



<li>expose the apache2 servers to the outside world</li>



<li>handle the TLS encryption</li>
</ul>



<p>First we setup <a href="https://letsencrypt.org/">Let&#8217;s Encrypt</a>. How to do this is beyond the scope of this article. You can look <a href="https://www.nginx.com/blog/using-free-ssltls-certificates-from-lets-encrypt-with-nginx/">here</a> for a good introduction.</p>



<p>The bottom line is that <code>certbot</code> must be installed, and the following public and private certificate files must exist on your server (host):</p>



<pre class="wp-block-code"><code>/etc/letsencrypt/live/example1.com/fullchain.pem
/etc/letsencrypt/live/example1.com/privkey.pem
/etc/letsencrypt/live/example2.com/fullchain.pem
/etc/letsencrypt/live/example2.com/privkey.pem</code></pre>



<p>These files are actually symlinks to the latest certificate issued. This is all handled by certbot.</p>



<p>Let&#8217;s start to create an nginx config file, which we will place in <code>/etc/nginx/sites-available/reverse-proxy.conf</code>.</p>



<p>We are going to enter several server <em>stanzas</em>, remembering that nginx will use the first one that matches in order from top to bottom.</p>



<h2 class="wp-block-heading">Redirects from http to https</h2>



<p>First, we want any unencrypted requests to port <code>80</code> to do a soft redirect to our <code>https://www.</code> sites.</p>



<pre class="wp-block-code"><code>server {
    listen       80;
    listen       &#91;::]:80;
    server_name example1.com;
    return 302 https://www.example1.com$request_uri;
}

server {
    listen       80;
    listen       &#91;::]:80;
    server_name example2.com;
    return 302 https://www.example2.com$request_uri;
}</code></pre>



<p>The first listen statement is for IPv4, and the second is for IPv6. We redirect to the TLS site, preserving the path segment of the request URI.</p>



<h2 class="wp-block-heading">Proxy forwarding</h2>



<p>Next we are going to enter the stanza that handles the actual site content:</p>



<pre class="wp-block-code"><code>server {
    listen      443 ssl;
    listen      &#91;::]:443 ssl;
    server_name www.example1.com;

    ssl_certificate /etc/letsencrypt/live/example1.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example1.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;

    location / {
        proxy_pass http://127.0.0.1:8101/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

server {
    listen      443 ssl;
    listen      &#91;::]:443 ssl;
    server_name www.example2.com;

    ssl_certificate /etc/letsencrypt/live/example2.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example2.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;

    location / {
        proxy_pass http://127.0.0.1:8102/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}</code></pre>



<p>Again, we are listening for <code>443</code> (the TLS port) on both IPv4 and IPv6.</p>



<p>Notice how we only listen for requests to the <code>www.</code> subdomain here.</p>



<p>We use the TLS certificates first, then we specify the reverse proxy in the <code>location /</code> section.</p>



<p>We forward each site to the correct port that we exposed with docker (<code>8101</code> and <code>8102</code> in this case).</p>



<p>We also set some <code>X-</code> headers. This is so that the PHP server knows some details about the client.</p>



<h2 class="wp-block-heading">Redirects from all subdomains to www</h2>



<p>Finally, we want requests from <code>https://example1.com</code>, or from ay other subdomain, such as <code>https://foo.example1.com</code>, to redirect to our <code>www.</code> subdomain:</p>



<pre class="wp-block-code"><code>server {
    listen 443 ssl;
    listen &#91;::]:443 ssl;
    server_name .example1.com;

    ssl_certificate /etc/letsencrypt/live/example1.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example1.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;

    location / {
        rewrite ^ https://www.example1.com permanent;
    }
}

server {
    listen 443 ssl;
    listen &#91;::]:443 ssl;
    server_name .example2.com;

    ssl_certificate /etc/letsencrypt/live/example2.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example2.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;

    location / {
        rewrite ^ https://www.example2.com permanent;
    }
}</code></pre>



<p>Here we listen for any subdomain. Note the dot (<code>.</code>) prefix in the <code>server_name</code>.</p>



<p>We again use the TLS certificates, but this time we perform a redirect to the <code>wwww.</code> subdomain.</p>



<h2 class="wp-block-heading">Administering our reverse proxy</h2>



<p>When we are ready to enable our reverse proxy, we will create a symlink to <code>sites-enabled</code>:</p>



<pre class="wp-block-code"><code>sudo ln -s /etc/nginx/sites-available/reverse-proxy.conf /etc/nginx/sites-enabled/reverse-proxy.conf</code></pre>



<p>We can test our syntax to see that it is correct with:</p>



<pre class="wp-block-code"><code>sudo nginx -t</code></pre>



<p>And finally we can restart the nginx server with:</p>



<pre class="wp-block-code"><code>sudo service nginx restart</code></pre>



<p>We can check the status of the server with:</p>



<pre class="wp-block-code"><code>sudo service nginx status</code></pre>



<p>If everything is working correctly, and if the DNS records have had time to propagate, then we can visit our sites and run the famous WordPress installation process:</p>



<ul class="wp-block-list">
<li>https://www.example1.com/</li>



<li>https://www.example2.com/</li>
</ul>



<h1 class="wp-block-heading">Emails</h1>



<p><strong>If only the above was enough.</strong> Sadly, our WordPress installations need a way to send emails, otherwise the webmaster experience is going to suck big time.</p>



<p>I say sadly, because setting up <code>sendmail</code> first on the host is relatively easy, but then setting up SMTP proxies in the WordPress containers is not something I am familiar with. Sorry guys, in the interest of keeping things simple, I&#8217;m going to cheat a little here. Here&#8217;s what I did:</p>



<ul class="wp-block-list">
<li>Install the free <a href="https://wpmailsmtp.com/" target="_blank" rel="noreferrer noopener">WP Mail SMTP</a> plugin on both sites.</li>



<li>Create an application-specific password in my google account.</li>



<li>In the WordPress admin screens, go to: <em>WP Mail SMTP</em> → <em>Mailer</em> → <em>Other SMTP</em>.</li>



<li>Enter the following settings:
<ul class="wp-block-list">
<li>SMTP Host: <code>smtp.gmail.com</code></li>



<li>Encryption: <code>SSL</code></li>



<li>SMTP Port: <code>465</code></li>



<li>Auto TLS: <code>ON</code></li>



<li>Authentication: <code>ON</code></li>



<li>SMTP Username: (my gmail address)</li>



<li>SMTP Password: (the application specific password that I just created).</li>
</ul>
</li>



<li>Hit <em>Save Settings</em>.</li>



<li>Go to <em>WP Mail SMTP</em> → <em>Tools</em> and send a test email.</li>
</ul>



<p>If everything works, then WordPress and its plugins can now send emails. But it will only be able to send email into spam folders, until we add a <a href="https://en.wikipedia.org/wiki/Sender_Policy_Framework" target="_blank" rel="noreferrer noopener">Sender Policy Framework (SPF)</a> record to our DNS entries:</p>



<figure class="wp-block-table"><table><thead><tr><th>Type</th><th>Hostname</th><th>Value</th><th>TTL</th></tr></thead><tbody><tr><td>TXT</td><td>example1.com</td><td>v=spf1 a mx ~all</td><td>1800</td></tr><tr><td>TXT</td><td>example2.com</td><td>v=spf1 a mx ~all</td><td>1800</td></tr></tbody></table><figcaption class="wp-element-caption"><em>Disclaimer: These DNS records are actually not related to sunscreen in any way.</em></figcaption></figure>



<p>The above TXT records tell recipients to treat all emails coming from servers pointed to by the A or MX record of your domains as safe, and others as potentially suspicious. Again, use your favorite AI chatbot to constuct an SPF record that matches your needs.</p>



<h1 class="wp-block-heading">Nothing more permanent than a 301 redirect</h1>



<p>If all works, it&#8217;s now time to turn the soft redirects into permanent (hard) redirects. Edit the reverse proxy config and change any <code>302</code> redirects to <code>301</code>. Any browsers visiting your site will cache these redirects for eternity.</p>



<p>It&#8217;s also now a good time to increase the Time-to-Live of all the DNS records to something like 4 hours, or <code>14400</code> seconds.</p>



<h1 class="wp-block-heading">Backups</h1>



<p>You would think that by now you&#8217;re finished, <strong>but you&#8217;d be wrong</strong>!</p>



<p>Any IT technician worth their <a href="https://en.wikipedia.org/wiki/Salt_(cryptography)" target="_blank" rel="noreferrer noopener">salt</a> knows that they must <a href="https://gist.github.com/nooges/817e5f4afa7be612863a7270222c36ff" target="_blank" rel="noreferrer noopener">backup, and backup often</a>.</p>



<p>First, turn off the server or droplet and take a full backup, snapshot, or whatever. Future you will thank you.</p>



<p>Then, let&#8217;s see how we can take automated daily backups. We can either pay the hosting provider every month to do this for us, or we can spend a few minutes to set up a few cron jobs. Let&#8217;s be cheap and do it manually.</p>



<p>I have a raspberry Pi at home that is always on. It does various things like take backups, ping various services and email me if they are down, trigger wp-cron URLs, control crypto miners, run services I need such as my ticket system, and in general runs any other odd 24/7 task. You should also have one such low-power system. The great thing with Raspberry Pi is that it&#8217;s easy to take out the MicroSD and gzip it into a mechanical disk, so the backup mechanism itself is nicely backed up in its entirety. (<a href="https://knowyourmeme.com/memes/xzibit-yo-dawg" target="_blank" rel="noreferrer noopener">Yo dawg, heard you like backups…</a>)</p>



<p>We&#8217;ll now use our local always-on Linux system to take daily backups of our online filesystems and databases:</p>



<h2 class="wp-block-heading">Local <code>backups.sh</code> script</h2>



<p>First, let&#8217;s create a DB user that only has enough access to take backups from both databases, but no more:</p>



<p>Login to the MySQL consoles of each database and create a <code>wp_bu</code> user that will do backups:</p>



<pre class="wp-block-code"><code>CREATE USER 'wp_bu'@'localhost' IDENTIFIED BY 'SOMESTRONGPASSWORD';
GRANT SELECT, LOCK TABLES ON wp_db1.* TO 'wp_bu'@'localhost';

CREATE USER 'wp_bu'@'localhost' IDENTIFIED BY 'SOMESTRONGPASSWORD';
GRANT SELECT, LOCK TABLES ON wp_db2.* TO 'wp_bu'@'localhost';</code></pre>



<p>We only need SELECT, but since we want to call <code>mysqldump</code> with the <code>--single-transaction</code> argument, we&#8217;ll also need to grant the <code>LOCK TABLES</code> permission. No point in having an ACID database if we&#8217;re going to take backups of an inconsistent state now, is there?</p>



<p>We&#8217;ll now create a bash shell script that does our daily backups. Let&#8217;s place it in our local backup server and call it <code>backups.sh</code>:</p>



<pre class="wp-block-code"><code>#!/bin/bash

# ensure dirs exist
mkdir -p /path-to-backups/cache/wp{1,2}volume /path-to-backups/server

# download DBs to SQL files
ssh -t server "docker exec droplet-wpdb-1 nice -n 19 mysqldump -u wp_bu -pSOMESTRONGPASSWORD --no-tablespaces --single-transaction wp_db1 | nice -n 19 gzip -9 -f" &gt;/path-to-backups/server/wp_db1-`date --rfc-3339=date`.sql.gz
ssh -t server "docker exec droplet-wpdb-2 nice -n 19 mysqldump -u wp_bu -pSOMESTRONGPASSWORD --no-tablespaces  --single-transaction wp_db2 | nice -n 19 gzip -9 -f" &gt;/path-to-backups/server/wp_db2-`date --rfc-3339=date`.sql.gz

# download wp-content files to backup cache
rsync -aq server:~/wp1fs/* /path-to-backups/cache/wp1volume
rsync -aq server:~/wp2fs/* /path-to-backups/cache/wp2volume

# Zip downloaded wp-content files
zip -r9q /path-to-backups/server/wp1-`date --rfc-3339=date`.zip /path-to-backups/cache/wp1volume -x "**/GeoLite2*" -x "**/GeoIPv6.dat"
zip -r9q /path-to-backups/server/wp2-`date --rfc-3339=date`.zip /path-to-backups/cache/wp2volume -x "**/GeoLite2*" -x "**/GeoIPv6.dat"

# prune old DB and FILE backups from local backups
cd /path-to-backups/server &amp;&amp; ls -1tr | head -n -30 | xargs -d '\n' rm -rf -</code></pre>



<p>Again, a lot goes on here. Let&#8217;s unpack:</p>



<ul class="wp-block-list">
<li>The script creates directories <code>server</code> and <code>cache</code> under <code>/path-to-backups</code>. Replace this path with something that points to the directory where you want to keep your backups.</li>



<li>We then <code>ssh</code> to the host using the <code>-t</code> argument because we are in a headless environment (cron). We issue a <code>docker exec</code> command into our databases. Notice how we do not use the <code>-it</code> arguments to <code>docker exec</code>, since this is a headless command (no TTY attached). The command is a <code>mysqldump</code> command that uses the credentials we just created to export the databases in a single transaction each. The SQL output is compressed with maximum compression (<code>-9</code>) and the binary output of <code>gzip</code> is forced (<code>-f</code>) into the standard output, which is then sent over the ssh connection. In our local backups server, we redirect this compressed stream into an <code>.sql.gz</code> file. The file name starts with <code>wp_db1-</code> and includes the current date in <code>YYYY-MM-DD</code> notation. (RFC 3339 is my idea of a perfect date, btw). The <code>--no-tablespaces</code> argument is need in MySQL <code>8.0.21</code> and later, otherwise you&#8217;ll need the PROCESS global permission. (Unless you are using tablespaces you don&#8217;t need it, hence the argument <code>--no-tablespaces</code>.) Notice that we make sure to be <code>nice</code> to other running processes because we don&#8217;t want to impact the performance of the web server with our backups. <code>19</code> is the idle CPU priority.</li>



<li>We then use <code>rsync</code> with the quiet (<code>-q</code>) and archive (<code>-a</code>) flags to copy the files of our WordPress installations into our <code>cache/wp1volume</code> and <code>cache/wp2volume</code> directories. The advantage of using rsync is that only changes to these directories will be transferred.</li>



<li>We then create a zip file for each of these directories. We name the zip files with the prefixes <code>wp1-</code> and <code>wp2-</code> followed again by our idea of a perfect date. Many WordPress plugins include a database of IPs mapped to geographical locations. These files are large and can be found online. If we don&#8217;t want to save these, we can exclude them (<code>-x</code> flag), but this is optional.</li>



<li>Finally we list the files we created (both <code>.sql.gz</code> and <code>.zip</code> files) and we only keep the last 30, deleting any older ones. Since we have two files for each of two databases, this will retain daily backups for the last week or so.</li>
</ul>



<p>Make the script executable with</p>



<pre class="wp-block-code"><code>chmod +x backups.sh</code></pre>



<p>We run the script once, and we check the <code>.sql.gz</code> files using <code>zless</code> and the zip files with <code>unzip -l</code>.</p>



<p>Once we are certain that all data is backed up by the script, we add it to the crontab. Edit the crontab with <code>crontab -e</code> and add the line:</p>



<pre class="wp-block-code"><code>20 4 * * * /bin/bash /home/yourusername/backups.sh</code></pre>



<p>This will execute the backups every day at 4:20 in the morning.</p>



<h2 class="wp-block-heading">Checking the backups</h2>



<p>The server works and is fully backed up. You would think that you&#8217;re done by now. That&#8217;s where <strong>you&#8217;d be wrong again</strong>!</p>



<p>Having backups and not checking them regularly is worse than not having backups at all: You are being lulled into a false sense of security. You may act precariously, thinking that you can always go back to the last backup. However, all backup mechanisms can fail, for any number of reasons.</p>



<p>What I do, is I&#8217;ve set up a weekly reminder in my Google calendar to check the backups. It only takes half a minute per week to ssh into my backup server and do an <code>ls -l</code>, thus ensuring that the latest backups exist, and their file size is what I&#8217;d expect. I keep old backups for about a week, hence the weekly reminder.</p>



<p>I also have another reminder every three months, to backup the MicroSD of my Raspberry Pi backup server. Once every three months, I shutdown the Pi, take out the MicroSD, put it into my work PC, and copy the entire image into a file, stored on my mechanical disk:</p>



<pre class="wp-block-code"><code>sudo dd if=/dev/sdf of=/mnt/bu/rpi-backup-`date --iso-8601=date`.img bs=4096 conv=sync,noerror status=progress
gzip -9 /mnt/bu/rpi-backup-`date --iso-8601=date`.img</code></pre>



<p>Only once I have this process setup I can sleep at night.</p>



<h1 class="wp-block-heading">Are we finished yet?</h1>



<p>By now you would think that we&#8217;re not finished yet, and that there&#8217;s more things to do. <strong>That&#8217;s where you&#8217;d be wrong!</strong></p>



<p>And for anyone wondering, <code>example1.com</code> is actually <a href="https://www.dashed-slug.net" target="_blank" rel="noreferrer noopener">https://www.dashed-slug.net</a> and <code>example2.com</code> is actually this blog, <a href="https://www.alexgeorgiou.gr" target="_blank" rel="noreferrer noopener">https://www.alexgeorgiou.gr</a>. There&#8217;s also a plain nginx container in there that serves static HTML files at <a href="https://wallets-phpdoc.dashed-slug.net" target="_blank" rel="noreferrer noopener">https://wallets-phpdoc.dashed-slug.net</a> .</p>



<p>My config is actually a little bit more complex than the one discussed above. To save some more server memory, I had to put both databases into the same MySQL container, and set up two different DB users with access restricted to each respective database. But you shouldn&#8217;t do this at home, because isolation!</p>



<p>This article is being served by the containers I discussed here, and will be backed up early tomorrow morning, via the mechanism I shared with you above. Which is pretty meta, if you think about it!</p>



<p>I never expected to compose such a long, self-contained article on containers and <code>docker compose</code>. But now it&#8217;s finished and I can hardly contain my excitement!</p>



<p>Thanks for sticking to the end. Hope you enjoyed.</p>
<p>The post <a href="https://www.alexgeorgiou.gr/two-dockerized-wordpress-sites/">📦 Two dockerized WordPress sites, with Let&#8217;s Encrypt, logging, SMTP relay, controlled by a systemd service, and daily backups</a> appeared first on <a href="https://www.alexgeorgiou.gr">Alexandros Georgiou</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.alexgeorgiou.gr/two-dockerized-wordpress-sites/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>🧁 Blueberry cupcake string placeholders</title>
		<link>https://www.alexgeorgiou.gr/blueberry-cupcake-string-placeholders/</link>
					<comments>https://www.alexgeorgiou.gr/blueberry-cupcake-string-placeholders/#respond</comments>
		
		<dc:creator><![CDATA[alexg]]></dc:creator>
		<pubDate>Mon, 27 Sep 2021 08:40:58 +0000</pubDate>
				<category><![CDATA[Blog]]></category>
		<category><![CDATA[cupcake]]></category>
		<category><![CDATA[database]]></category>
		<category><![CDATA[dream]]></category>
		<category><![CDATA[prepare]]></category>
		<category><![CDATA[SQL]]></category>
		<category><![CDATA[wordpress]]></category>
		<guid isPermaLink="false">https://www.alexgeorgiou.gr/?p=354</guid>

					<description><![CDATA[<p>Apologies to my usual readers, but this post is not going to be technical. It&#8217;s about a dream I just had, today, Monday the 27th. In my dream, I was programming a custom SQL query for some WordPress plugin. The query was an INSERT statement, that simply passed a vector of strings into a table ...</p>
<p>The post <a href="https://www.alexgeorgiou.gr/blueberry-cupcake-string-placeholders/">🧁 Blueberry cupcake string placeholders</a> appeared first on <a href="https://www.alexgeorgiou.gr">Alexandros Georgiou</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p>Apologies to my usual readers, but this post is not going to be technical. It&#8217;s about a dream I just had, today, Monday the 27th.</p>



<p>In my dream, I was programming a custom SQL query for some WordPress plugin. The query was an <code>INSERT</code> statement, that simply passed a vector of strings into a table row of <code>VARCHAR</code> columns. I remember the code vividly, but table/column names were not important so I don&#8217;t remember all of them. There was a first name column, and a last name column. It must have looked something like:</p>



<pre class="wp-block-preformatted">	global $wpdb;
	
	$query = $wpdb-&gt;prepare(
		"INSERT INTO sometable(
			firstname,
			lastname,
			arg1,
			arg2,
			arg3,
			arg4
		)
		VALUES(
			%s,
			%s,
			%s,
			%s,
			%s,
			%s
		)"
	);</pre>



<p>I had dev-tested this in my dream and it was working fine. Until I once passed a string with two words, separated by a space character, into one of those arguments. Then I saw some error in the SQL logs. Turns out, in my dream, the SQL server was happy to accept one-word strings, even without surrounding quotes!!! Kind of like the syntax for strings in CSV. (I know, I have the wildest dreams!)</p>



<p>And to make things even weirder, for some reason, the <code>prepare()</code> function did not add these quotes; I had to supply them myself. Why, prepare function? WHY? You had ONE job!</p>



<p>Oh well, I thought to myself. Easy fix. I will surround the <code>%s</code> placeholders with double quotes myself. But as I started to do this, the list of <code>%s</code> became longer and longer. There must have been like 20 or 30 arguments in that list. Not a job to do by hand.</p>



<p>Naturally, all I had to do is use my editor to auto-replace the <code>%s</code>, with <code>"%s"</code>. But then, again, complications arose. The placeholders in the prepare statement were not in the usual <code>sprintf</code> syntax. Instead of <code>%s</code>, they conveniently were the Unicode characters for cake, muffin, and other assorted confectioneries. How did I not notice this before?</p>



<p>Oh well, I must have been hungry, because it seemed fairly reasonable at the time, that the prepare statement accepted placeholders like cupcakes, pies, bagels and pancakes. So now the VALUES vector looked more like:</p>



<pre class="wp-block-preformatted">	VALUES(
		?,
		?,
		?,
		?,
		?,
		?,
		?,
		?,
		?,
		?,
		?,
		?,
		?,
		?,
		?,
		?,
		?,
		?
	)</pre>



<p>I didn&#8217;t stop for a second to think about how weird this is, or even to check the <code>wpdb</code> manual again. Instead, I proceeded to wrap these tasty treats with double quotes.</p>



<p>But lo and behold! Another thing not turning out as usual! The IDE editor had trouble with Unicode characters. I would paste in a cupcake character into the search and replace box, and it would turn into an escape-code. In this case it would have been <code>\U1F95E</code>. (No, I do not remember the exact code from the dream, I had to look it up.)</p>



<p>As is usual with dreams, I did not stop to think why my IDE doesn&#8217;t have Unicode support in 2021. Instead, I shrugged it off as another odd thing that just happened, because reasons. I copied the query into another editor, and started replacing the tasty string placeholders, one by one, in that other editor.</p>



<p>That&#8217;s when my girlfriend woke me up. It was Monday morning.</p>



<p>She was somewhat upset, because she had to go to work and she was running late. I, on the other hand, was looking forward for work. After all, who doesn&#8217;t like cupcakes, pies, bagels and pancakes?</p>



<p>I know she reads my blog, so I just want to wish her, wholeheartedly, a very, very nice and easy day at work. <code>\U2665</code></p>



<p>And to anyone else reading this, sorry for wasting your time with my nonsense!</p>
<p>The post <a href="https://www.alexgeorgiou.gr/blueberry-cupcake-string-placeholders/">🧁 Blueberry cupcake string placeholders</a> appeared first on <a href="https://www.alexgeorgiou.gr">Alexandros Georgiou</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.alexgeorgiou.gr/blueberry-cupcake-string-placeholders/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>🖧 The wacky world of network activated WordPress plugins in multisite</title>
		<link>https://www.alexgeorgiou.gr/network-activated-wordpress-plugins/</link>
					<comments>https://www.alexgeorgiou.gr/network-activated-wordpress-plugins/#comments</comments>
		
		<dc:creator><![CDATA[alexg]]></dc:creator>
		<pubDate>Mon, 19 Jun 2017 17:46:25 +0000</pubDate>
				<category><![CDATA[Blog]]></category>
		<category><![CDATA[cron]]></category>
		<category><![CDATA[database]]></category>
		<category><![CDATA[development]]></category>
		<category><![CDATA[documentation]]></category>
		<category><![CDATA[multisite]]></category>
		<category><![CDATA[multiuser]]></category>
		<category><![CDATA[network activation]]></category>
		<category><![CDATA[site-wide]]></category>
		<category><![CDATA[SQL]]></category>
		<category><![CDATA[wordpress]]></category>
		<category><![CDATA[wp-cli]]></category>
		<category><![CDATA[wpmu]]></category>
		<guid isPermaLink="false">http://www.alexgeorgiou.gr/?p=224</guid>

					<description><![CDATA[<p>Insider secrets to making your plugin work well on multisite installs, both when it is network activated and when it is not.</p>
<p>The post <a href="https://www.alexgeorgiou.gr/network-activated-wordpress-plugins/">🖧 The wacky world of network activated WordPress plugins in multisite</a> appeared first on <a href="https://www.alexgeorgiou.gr">Alexandros Georgiou</a>.</p>
]]></description>
										<content:encoded><![CDATA[<p><em>Multisite</em> is arguably one of the weirdest features in <em>WordPress</em>. Making your plugin work well on multisite installs, both when it is network activated and when it is not, is one of the most fun things that you will have to do as a WordPress plugin developer. And by <em>fun</em>, I mean <em>not fun</em>. Not fun at all.</p>
<p>Introduced in 3.0, the multisite feature feels a little awkward when developing code, partly because things you take for granted in single-site installs are simply not there, partly because other things are unnecessarily different, partly because of the shifting terminology that has evolved over time, and partly because of the significantly less documentation, compared to that available on single-site features. Moreover there are significantly less articles about it out there, since most people care about single-site installs.</p>
<p>But, it works! After all, last time I checked, <a href="http://wordpress.com" target="_blank" rel="noopener noreferrer">wordpress.com</a> was up and running! The fact that people were able to extend WordPress so drastically in a way that actually works, is impressive to say the least. I can only imagine how hard it must have been. The idea is that you can make your WordPress behave just like <code>wordpress.com</code>, where users can create their own site.</p>
<p>So yes, some things about <strong>network-activated plugins</strong> on multisite suck, but they don&#8217;t suck to the point of being unusable. You just need to learn a few tricks. Fortunately, you came across this article, so all will become clear. Or will they?</p>
<h2>Some basic terminology</h2>
<p>A <em>multisite</em> (aka <em>MS</em>) install is sometimes also called a <em>network</em>. A network can have a multitude of <em>sites</em>, and sites can have many <em>blogs</em>. Confusingly, <em>site</em> can also mean <em>blog</em> besides <em>network</em>. And <em>network</em> does not mean what we normally mean in IT, it means a collection of <em>sites</em> or <em>multi-sites</em>, depending on who you ask. And a <em>blog</em> of course is not necessarily an actual weblog, but can be any type of <em>site</em>.</p>
<p>To add to the fun, in the past the <em>multisite</em> feature was named <em>multiuser</em> (or WPMU, or MU for short). Presumably this was changed because <em>multiuser</em> is a stupid name for this feature: all WordPress installations can have multiple users. But you do need to be aware of all the terms out there, because you will come across them at some point.</p>
<p>To paraphrase an old saying,</p>
<blockquote><p>There are only two hard problems in computer science, cache invalidation and naming WordPress things.</p></blockquote>
<p>Users who create blogs, or sites, or whatever (sigh!) on your <em>multisite</em> are <em>administrators</em> of their respective sites, and you, the owner of the <em>network</em> are a <em>network admin</em>, also known as a <em>super admin</em>. The <em>network admin</em> can administer global settings for the entire <em>network</em> from the <em>network admin menu</em>.</p>
<h2>What does network activated actually mean?</h2>
<p>Themes and plugins can either be:</p>
<ul>
<li><strong>activated</strong> on individual blogs by <em>administrators</em>, if the administrators have the capability to do so, or</li>
<li><strong>network-activated</strong> once, site-wide, by the <em>network admin</em>, making them available to all blogs.</li>
</ul>
<h2>Custom DB tables</h2>
<p>Most tables in the DB are duplicated for each blog, where the table name prefix contains the blog ID, so that, for example, options for blogs 3 and 4 are safely stored in tables <code>wp_3_options</code> and <code>wp_4_options</code> respectively. This allows for blog administrators to activate and configure their plugins separately for their blogs. As long as they activate the plugins themselves, everything should work just fine for most plugins, out of the box.</p>
<p>If you are maintaining custom DB tables, these will need some extra coding work. There are two ways to do this: Either</p>
<ul>
<li>add a <code>blog_id</code> column to your rows, and then filter your SQL queries by <code>blog_id=<a href="https://codex.wordpress.org/Function_Reference/get_current_blog_id" target="_blank" rel="noopener noreferrer">get_current_blog_id()</a></code>, or</li>
<li>create separate tables for each blog when your plugin activates, by binding with the <a href="https://codex.wordpress.org/Function_Reference/register_activation_hook" target="_blank" rel="noopener noreferrer"><code>register_activation_hook()</code></a> function, and also create tables whenever a new blog is created after your plugin has been activated, by binding to <a href="https://codex.wordpress.org/Plugin_API/Action_Reference/wpmu_new_blog"><code>wpmu_new_blog</code></a>.</li>
</ul>
<p>You can read more about this here:</p>
<blockquote data-secret="cBkskEYduB" class="wp-embedded-content"><p><a href="http://sudarmuthu.com/blog/how-to-properly-create-tables-in-wordpress-multisite-plugins/">How To Properly Create Tables In WordPress Multisite Plugins</a></p></blockquote>
<p><iframe class="wp-embedded-content" sandbox="allow-scripts" security="restricted"  src="http://sudarmuthu.com/blog/how-to-properly-create-tables-in-wordpress-multisite-plugins/embed/#?secret=cBkskEYduB" data-secret="cBkskEYduB" width="600" height="338" title="&#8220;How To Properly Create Tables In WordPress Multisite Plugins&#8221; &#8212; Night Dreaming (by Sudar)" frameborder="0" marginwidth="0" marginheight="0" scrolling="no"></iframe></p>
<h2>To network activate, or not to network activate?</h2>
<p>Depending on what your plugin actually does, you might want to have parts of it operate site-wide. This often makes sense, but know that in doing so you might be opening a big can of worms. The network admin area introduces its own set of actions and filters, its own menu structure, its own URL structure, a somewhat lacking settings API where you have to do some things manually, and you will have to think about how your <code>wp_cron</code> hooks work, new user capabilities, and different plugin activation code.</p>
<p>The function <a href="https://codex.wordpress.org/Function_Reference/is_multisite" target="_blank" rel="noopener noreferrer"><code>is_multisite()</code></a> tells you whether the WordPress install is multisite, but it tells you nothing about whether your plugin is activated for the network or whether it was activated at the blog level.</p>
<p>To find out whether your plugin is network activated or not, first import <a href="https://codex.wordpress.org/Function_Reference/is_plugin_active_for_network" target="_blank" rel="noopener noreferrer">the right function</a>:</p>
<pre>if ( ! function_exists( 'is_plugin_active_for_network' ) ) {
    require_once( ABSPATH . '/wp-admin/includes/plugin.php' );
}</pre>
<p>Then, you can do this:</p>
<pre><code>if ( is_plugin_active_for_network( 'myplugin/myplugin.php' ) ) {</code> // do stuff }</pre>
<p>assuming that your plugin&#8217;s slug is <code>myplugin</code>. The function will always return <code>false</code> on single-site installs.</p>
<h2>So many options&#8230;</h2>
<p>If some options must apply to the entire network, you will need to expose a menu of panels with those options to the network administrator.</p>
<h3>Know your DB options</h3>
<p>Those options can be thought of as &#8220;global&#8221; for a site (aka network), and they ought to be stored in the <code>wp_sitemeta</code> table. The table name is programmatically available as <code>$wpdb-&gt;sitemeta</code>. It is just like the <code>wp_options</code> table, but totally different: Columns <code>option_name</code> and <code>option_value</code> correspond to <code>meta_key</code> and <code>meta_value</code>. If you&#8217;re wondering about the <code>site_id</code> column, no, it&#8217;s not an identifier to individual blogs, it&#8217;s a unique identifier to a site, because, yes, you can have several sites on one network, each with several blogs.</p>
<h3>:s/_option/_site_option/</h3>
<p>These site meta options are accessed not by the usual functions <a href="https://developer.wordpress.org/reference/functions/get_option/" target="_blank" rel="noopener noreferrer"><code>get_option()</code></a> and friends, but by <a href="https://codex.wordpress.org/Function_Reference/get_site_option" target="_blank" rel="noopener noreferrer"><code>get_site_option()</code></a>, <a href="https://codex.wordpress.org/Function_Reference/update_site_option" target="_blank" rel="noopener noreferrer"><code>update_site_option()</code></a>, <a href="https://codex.wordpress.org/Function_Reference/add_site_option" target="_blank" rel="noopener noreferrer"><code>add_site_option()</code></a> and <a href="https://codex.wordpress.org/Function_Reference/delete_site_option" target="_blank" rel="noopener noreferrer"><code>delete_site_option()</code></a>.</p>
<h3>Option-related capabilities</h3>
<p>You might be interested in the <a href="https://codex.wordpress.org/Roles_and_Capabilities#manage_network_options" target="_blank" rel="noopener noreferrer"><code>manage_network_options</code></a> capability that is normally granted to Super Admins. In fact, have a look at <a href="https://codex.wordpress.org/Roles_and_Capabilities#manage_network" target="_blank" rel="noopener noreferrer">Roles and Capabilities</a> in the Codex to make sure you know about all of the <code>manage_network_*</code> capabilities, as well as the <code>*_sites</code> capabilities. Essential reading if you&#8217;re a network admin.</p>
<h3>Updating site options</h3>
<p>Know that you will have to do things differently when creating admin forms for network-wide options. In a nutshell:</p>
<ul>
<li>you need to hook to the <code>network_admin_menu</code> action rather than <code>admin_menu</code>.</li>
<li>your HTML forms need to have a different <code>action</code> attribute,</li>
<li>you need to bind a submit handler to:
<ul>
<li>check for the admin nonce,</li>
<li>actually save the settings to the DB, and</li>
<li>redirect back to the right admin panel.</li>
</ul>
</li>
<li>Use <a href="https://codex.wordpress.org/Function_Reference/network_admin_url"><code>network_admin_url()</code></a> wherever you&#8217;d normally use <a href="https://codex.wordpress.org/Function_Reference/admin_url"><code>admin_url()</code></a>.</li>
</ul>
<p>I will not go through all of this detail here, as it is explained very well in this rare article:</p>
<blockquote data-secret="Gc6F9iV1q6" class="wp-embedded-content"><p><a href="https://vedovini.net/2015/10/using-the-wordpress-settings-api-with-network-admin-pages/">Using the WordPress Settings API with Network Admin pages</a></p></blockquote>
<p><iframe class="wp-embedded-content" sandbox="allow-scripts" security="restricted"  src="https://vedovini.net/2015/10/using-the-wordpress-settings-api-with-network-admin-pages/embed/#?secret=Gc6F9iV1q6" data-secret="Gc6F9iV1q6" width="600" height="338" title="&#8220;Using the WordPress Settings API with Network Admin pages&#8221; &#8212; vedovini.net" frameborder="0" marginwidth="0" marginheight="0" scrolling="no"></iframe></p>
<p>I find this article tells you all that you need to know about how to actually save site meta, but if you want to read more, you can <a href="https://codex.wordpress.org/Creating_Options_Pages#Pitfalls" target="_blank" rel="noopener noreferrer">have a look at the official documentation</a>, which currently simply links to <a href="https://wordpress.stackexchange.com/questions/64968/settings-api-in-multisite-missing-update-message/72503#72503" target="_blank" rel="noopener noreferrer">here</a> and <a href="https://web.archive.org/web/20111102180213/http://code.hyperspatial.com/1250/save-plugin-options-multisite-3-1" target="_blank" rel="noopener noreferrer">here</a>.</p>
<h3>Getting hooked to site-wide options yet? Not to worry, there&#8217;s more!</h3>
<p>Keep in mind that there exists a multitude of hooks that are specific to network administration. For example, to do various custom stuff to a site-wide option before saving it to the DB, do not bind to <a href="https://codex.wordpress.org/Plugin_API/Filter_Reference/pre_update_option_(option_name)" target="_blank" rel="noopener noreferrer"><code>pre_update_option_{$option_name}</code></a>, but to <a href="https://developer.wordpress.org/reference/hooks/pre_update_site_option_option/" target="_blank" rel="noopener noreferrer"><code>pre_update_site_{$option_name}</code></a>.</p>
<h3>Set option values via the terminal like a 1334 h@x0r</h3>
<p>If you&#8217;re like me, you probably have set up your build process to auto-inject options to your dev environment, using <code>wp-cli</code>. Instead of</p>
<pre> wp option update option_name option_value</pre>
<p>do something like this instead:</p>
<pre>wp network meta set 1 option_name option_value</pre>
<p>where <code>1</code> is the number of your multisite environment.</p>
<h3>How to nag the right admin</h3>
<p>To show notices to the <em>network admin</em>, do not bind to <a href="https://codex.wordpress.org/Plugin_API/Action_Reference/admin_notices" target="_blank" rel="noopener noreferrer"><code>admin_notices</code></a>, instead bind to <a href="https://codex.wordpress.org/Plugin_API/Action_Reference/network_admin_notices" target="_blank" rel="noopener noreferrer"><code>network_admin_notices</code></a>.</p>
<h3>The surprising secret argument of the activation hook that nobody knew about!!!</h3>
<p>Apologies, when I&#8217;m blogging I sometimes go into full SEO mode. Anyhow&#8230;</p>
<p>For your activation handler to work, you will first need to bind a function with <a href="https://codex.wordpress.org/Function_Reference/register_activation_hook" target="_blank" rel="noopener noreferrer"><code>register_activation_hook()</code></a>, as usual. It turns out that your activation handler can take a parameter that tells you whether the plugin is being network-activated or not.</p>
<p>Here&#8217;s a handy way I like to use to create options that can either be global on the multisite level, or bound to the current blog, depending on whether your plugin was network-activated or activated on a single blog:</p>
<pre>function activation_hook( $network_active ) {
    call_user_func(
        $network_active ? 'add_site_option' : 'add_option',
        'option_name',
        'option_value'
    );
}
register_activation_hook( __FILE__, 'activation_hook' );</pre>
<h3>Clean up after yourself!</h3>
<p>Remember to do the same in your <code>uninstall.php</code>. You do clean up after yourself, don&#8217;t you? Of course you do, you&#8217;re a developer, not a filthy pig.</p>
<p>Remember, you will not be able to use <a href="https://codex.wordpress.org/Function_Reference/is_plugin_active_for_network" target="_blank" rel="noopener noreferrer"><code>is_plugin_active_for_network()</code></a>, because by the time the uninstall code runs, the plugin should have already been deactivated. Since the plugin is being uninstalled completely, you can do something like the following, just to be on the safe side:</p>
<pre>// delete from site meta
delete_site_option( 'option_name' );

// also delete from options table for all blogs
global $wpdb;
foreach ( $wpdb-&gt;get_col( "SELECT blog_id FROM $wpdb-&gt;blogs" ) as $blog_id ) {
    switch_to_blog( $blog_id );
    delete_option( 'option_name' );
    restore_current_blog();
}</pre>
<p>You will also likely want to delete any custom tables you may have created. The trick here is to iterate over all the blogs and let WordPress choose the right DB prefix.</p>
<pre>foreach ( $wpdb-&gt;get_col( "SELECT blog_id FROM $wpdb-&gt;blogs" ) as $blog_id ) {
    switch_to_blog( $blog_id );
    $wpdb-&gt;query( "DROP TABLE IF EXISTS {$wpdb-&gt;prefix}mytable" );
    restore_current_blog();
}</pre>
<p>If you have chosen the separate tables way, you should also hook a <code>DROP TABLE</code> query to the <a href="https://developer.wordpress.org/reference/hooks/delete_blog/" target="_blank" rel="noopener noreferrer"><code>delete_blog</code></a> hook:</p>
<pre>function delete_blog_tables( $blog_id, $drop ) {
    if ( $drop ) {
        switch_to_blog( $blog_id );
        $wpdb-&gt;query( "DROP TABLE IF EXISTS {$wpdb-&gt;prefix}mytable" );
        restore_current_blog();
    }
}
add_action( 'delete_blog', 'delete_blog_tables' );</pre>
<p>If on the other hand you have one table for all blogs, and your rows have a <code>blog_id</code> column, do something more in the tune of:</p>
<pre>function delete_blog_tables( $blog_id, $drop ) {
    if ( $drop ) {
        $wpdb-&gt;query( "DELETE FROM {$wpdb-&gt;prefix}mytable WHERE blog_id = $blog_id" );
    }
}
add_action( 'delete_blog', 'delete_blog_tables' );</pre>
<p>There! All clean now!</p>
<h3>Cron</h3>
<p>You will want to use that handy little <em>foreach</em> loop in more than just the uninstall script. For instance, suppose you&#8217;re using <a href="https://developer.wordpress.org/plugins/cron/" target="_blank" rel="noopener noreferrer">cron</a>. If your plugin is network activated, cron will run once for the network, not once per blog. Assuming your cron handler needs to do stuff to each blog, you&#8217;d do something like:</p>
<pre>function cron_handler( ) {
    if ( is_plugin_active_for_network( 'myplugin/myplugin.php' ) ) {
        global $wpdb;
        foreach ( $wpdb-&gt;get_col( "SELECT blog_id FROM $wpdb-&gt;blogs" ) as $blog_id ) {
            switch_to_blog( $blog_id );
            do_stuff();
            restore_current_blog();
        }
    } else {
        do_stuff();
    }
}
add_action( 'cron_hook', 'cron_handler' );

if ( false === wp_next_scheduled( 'cron_hook' ) ) {
    wp_schedule_event( time(), 'every_now_and_then', 'cron_hook' );
}

function do_stuff() {
    // do your stuff here
}</pre>
<p>This way if your plugin runs network-wide, then your stuff is done on each one of the blogs on your multisite. But if the plugin is activated on the blog level, it does stuff only on the current blog.</p>
<p>Good stuff!</p>
<h2><del datetime="2017-08-13T18:22:13+00:00">Super secret plugin header: Network</del></h2>
<p><strong>Edit: This plugin header seems to not be respected any more; perhaps this is why it is not documented. It is part of WordPress history.</strong></p>
<p><del datetime="2017-08-13T18:22:13+00:00">Although you wouldn&#8217;t have guessed it from <a href="https://developer.wordpress.org/plugins/the-basics/header-requirements/" target="_blank" rel="noopener noreferrer">reading the relevant documentation</a>, the plugin header, i.e. that special block of comments that you put at the top of your main plugin file, can have something to say about network activation. (A tiny hint is given <a href="https://codex.wordpress.org/File_Header" target="_blank" rel="noopener noreferrer">in the Codex</a> that such a header exists, although the documentation does not currently bother explaining what it does. After all, if everything was documented, where would the fun be?)</del></p>
<p><del datetime="2017-08-13T18:22:13+00:00">If your plugin is intended to be <strong>only network activated</strong>, and not activated on the blog-level in multi-sites, then add this line to your plugin&#8217;s header:</del></p>
<pre>Network: True</pre>
<p><del datetime="2017-08-13T18:22:13+00:00">Now WordPress will not give the activation option to mere blog admins.</del></p>
<h2>Are you still with me?</h2>
<p>Awesome! There you have it! Now you know all that I currently know about WordPress multisite and network activated plugins. Which is arguably not a lot. If you know more and feel like sharing, you&#8217;re free to comment below.</p>
<p>Hopefully you now have some grasp of the types of things you should be looking out for when you take your plugin for a ride into the wacky world of network activation.</p>
<p>Take care.</p>
<p>Alex</p>
<p>The post <a href="https://www.alexgeorgiou.gr/network-activated-wordpress-plugins/">🖧 The wacky world of network activated WordPress plugins in multisite</a> appeared first on <a href="https://www.alexgeorgiou.gr">Alexandros Georgiou</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.alexgeorgiou.gr/network-activated-wordpress-plugins/feed/</wfw:commentRss>
			<slash:comments>2</slash:comments>
		
		
			</item>
		<item>
		<title>How to migrate trac MySQL-based project from trac 1.0.12 to trac 0.11.7</title>
		<link>https://www.alexgeorgiou.gr/migrate-trac-1-0-12-to-0-11-7/</link>
					<comments>https://www.alexgeorgiou.gr/migrate-trac-1-0-12-to-0-11-7/#respond</comments>
		
		<dc:creator><![CDATA[alexg]]></dc:creator>
		<pubDate>Mon, 13 Mar 2017 20:25:14 +0000</pubDate>
				<category><![CDATA[Blog]]></category>
		<category><![CDATA[backup]]></category>
		<category><![CDATA[bash]]></category>
		<category><![CDATA[database]]></category>
		<category><![CDATA[downgrade]]></category>
		<category><![CDATA[issue tracking]]></category>
		<category><![CDATA[mysqldump]]></category>
		<category><![CDATA[project management]]></category>
		<category><![CDATA[SQL]]></category>
		<category><![CDATA[trac]]></category>
		<guid isPermaLink="false">http://www.alexgeorgiou.gr/?p=199</guid>

					<description><![CDATA[<p>How to migrate trac project from 1.0.12 to a 0.11.7 installation if your project is MySQL based.</p>
<p>The post <a href="https://www.alexgeorgiou.gr/migrate-trac-1-0-12-to-0-11-7/">How to migrate trac MySQL-based project from trac 1.0.12 to trac 0.11.7</a> appeared first on <a href="https://www.alexgeorgiou.gr">Alexandros Georgiou</a>.</p>
]]></description>
										<content:encoded><![CDATA[<p>In a world dominated by <a href="https://www.atlassian.com/software/jira" target="_blank" rel="noopener noreferrer"><code>JIRA</code></a>, some still dare to use <a href="https://trac.edgewall.org/" target="_blank" rel="noopener noreferrer"><code>trac</code></a>&#8230; Here&#8217;s how to migrate <code>trac</code> to an earlier version, assuming this was a good idea, which of course it is not!</p>
<h2>Versions</h2>
<p>On my Ubuntu 16 installation (let&#8217;s call it <code>example1.com</code>) I have a project on this version of <code>trac</code>:</p>
<pre>$ lsb_release  -d
Description:    Ubuntu 16.10
$ tracd --version
tracd 1.0.12</pre>
<p>I had good(-ish) reasons to migrate the entire project to a copy on an Ubuntu 10 machine.</p>
<pre>$ lsb_release -d
Description:    Ubuntu 10.04.4 LTS</pre>
<h2>Installing trac</h2>
<p>These are the resources that you need to be aware of:</p>
<ul>
<li><a href="https://trac.edgewall.org/wiki/TracOnUbuntu" target="_blank" rel="noopener noreferrer">https://trac.edgewall.org/wiki/TracOnUbuntu</a></li>
<li><a href="https://trac.edgewall.org/wiki/MySqlDb" target="_blank" rel="noopener noreferrer">https://trac.edgewall.org/wiki/MySqlDb</a></li>
</ul>
<p>I first installed <code>trac</code> via the <code>old-releases.ubuntu.com</code> repository.</p>
<pre>$ sudo apt-get install trac</pre>
<p>This installed an earlier version than the one I had.</p>
<pre class="wiki">$ tracd --version
tracd 0.11.7</pre>
<h2>Setting up the new (old) trac</h2>
<p>Rather than upgrading the entire system with <code>do-release-upgrade</code>, I decided to work with this version.</p>
<p>You will need the <code>python-mysqldb</code> module on <code>example2.com</code> to access a MySQL database. If you&#8217;re using PostgreSQL the procedure <em>should</em> be similar. YMMV.</p>
<pre class="wiki">$ sudo apt-get install python-mysqldb</pre>
<p>First, copy the assets of the project environment. This is a directory tree that you can copy over using <code>rsync</code>.</p>
<pre class="wiki">rsync -r example1.com:/path-to-trac-env example2.com:/path-to-trac-env</pre>
<p>For simplicity we&#8217;ll keep everything the same: file directories, database name and database credentials, although you could change these. Your database settings are in the <code>/path-to-trac-env/conf/trac.ini</code> file. You want the database variable under the <code>[trac]</code> section, which should look something like:</p>
<pre>[trac]

database = mysql://tracuser:tracpassword@localhost:3306/trac-env</pre>
<p>You will want to create an empty MySQL database where your project will live. You will need the MySQL client on <code>example2.com</code>, so if you don&#8217;t have it, install it with:</p>
<pre>sudo apt-get install mysql-client</pre>
<p>You will then want to login as root</p>
<pre>mysql -u root -p</pre>
<p>and create the database, (using the <code>utf8_bin</code> collation)</p>
<pre class="wiki">CREATE DATABASE `trac-env` DEFAULT CHARACTER SET utf8 COLLATE utf8_bin;</pre>
<p>and finally create the user:</p>
<pre class="wiki">GRANT ALL ON `trac-env`.* TO tracuser@localhost IDENTIFIED BY 'tracpassword';</pre>
<p>Exit the MySQL client with <code>Ctrl-D</code> and import a standard SQL dump of your database from <code>example1.com</code>. For instance, if you like one-liners, you might do this on <code>example2.com</code>:</p>
<pre class="wiki">ssh example1.com "mysqldump -u tracuser -ptracpassword --single-transaction trac-env | gzip -9" | zcat | mysql -u tracuser -ptracpassword trac-env</pre>
<h2>Nasty hack FTW</h2>
<p>Now start <code>tracd</code> and visit the installation via your web browser. You should see an error like this:</p>
<pre class="wiki"> Trac detected an internal error:

ValueError: timestamp out of range for platform time_t</pre>
<p>This is because all timestamps in this newer version of <code>trac</code> are in microseconds, rather than seconds. Here is <a href="https://trac.edgewall.org/ticket/9314">a (somewhat) relevant ticket</a>.</p>
<p>I went ahead and hacked the DB thusly:</p>
<pre class="wiki">update ignore attachment set time = floor( time / 10000000);
update ignore auth_cookie set time = floor( time / 10000000);
update ignore revision set time = floor( time / 10000000);
update ignore ticket set time = floor( time / 10000000);
update ignore ticket set changetime = floor( changetime / 10000000);
update ignore ticket_change set time = floor( time / 10000000);
update ignore version set time = floor( time / 10000000);
update ignore wiki set time = floor( time / 10000000);</pre>
<p>These are all the timestamp columns in the DB. Dividing by a million converts millionths of a second to seconds and the <code>floor</code> function makes sure that the result is still an integer.</p>
<p>The <code>ignore</code> argument is there only because I had one single collision in one ticket comment. Needless to say, this is a nasty hack, so don&#8217;t rely on it too much.</p>
<p>But, for my purposes, <strong>&#8220;It Works<img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2122.png" alt="™" class="wp-smiley" style="height: 1em; max-height: 1em;" />&#8221;!</strong></p>
<p>The post <a href="https://www.alexgeorgiou.gr/migrate-trac-1-0-12-to-0-11-7/">How to migrate trac MySQL-based project from trac 1.0.12 to trac 0.11.7</a> appeared first on <a href="https://www.alexgeorgiou.gr">Alexandros Georgiou</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.alexgeorgiou.gr/migrate-trac-1-0-12-to-0-11-7/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>auto_increment flag repair on primary keys of a WordPress MySQL database</title>
		<link>https://www.alexgeorgiou.gr/repair-auto_increment-primary-key-wordpress-mysql/</link>
					<comments>https://www.alexgeorgiou.gr/repair-auto_increment-primary-key-wordpress-mysql/#comments</comments>
		
		<dc:creator><![CDATA[alexg]]></dc:creator>
		<pubDate>Sun, 18 Dec 2016 12:52:02 +0000</pubDate>
				<category><![CDATA[Blog]]></category>
		<category><![CDATA[auto_increment]]></category>
		<category><![CDATA[backup]]></category>
		<category><![CDATA[database]]></category>
		<category><![CDATA[error]]></category>
		<category><![CDATA[export]]></category>
		<category><![CDATA[import]]></category>
		<category><![CDATA[mysql]]></category>
		<category><![CDATA[mysqldump]]></category>
		<category><![CDATA[primary key]]></category>
		<category><![CDATA[SQL]]></category>
		<category><![CDATA[wordpress]]></category>
		<guid isPermaLink="false">http://www.alexgeorgiou.gr/?p=173</guid>

					<description><![CDATA[<p>Here's some code to restore the auto_increment flag on the primary keys of a WordPress MySQL database after a faulty export/import cycle.</p>
<p>The post <a href="https://www.alexgeorgiou.gr/repair-auto_increment-primary-key-wordpress-mysql/">auto_increment flag repair on primary keys of a WordPress MySQL database</a> appeared first on <a href="https://www.alexgeorgiou.gr">Alexandros Georgiou</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p><em>Here&#8217;s some code that I used to restore the auto_increment flag on the primary keys of my WordPress MySQL database after a somewhat faulty export/import cycle.</em></p>



<h2 class="wp-block-heading">The auto_increment problem and its symptoms</h2>



<p>When you export the WordPress database and then import it again, either via <code>phpmyadmin</code> or via <code>mysqldump</code> and the <code>mysql</code> CLI, all sorts of things can (and often will) go wrong. This can be understandably very stressful, especially on a live system. Please take a deep breath and read on.</p>



<p>I did a dump of my (fortunately development) environment and then imported it again. At first glance, all was working perfectly.</p>



<p>Then, all of a sudden I noticed that the <strong>Administrator</strong> user had no rights to create posts or pages. The usual <strong>Publish</strong> button was replaced with <strong>Submit for review</strong>.</p>



<p>Naïvely my first thought was to install <a href="https://wordpress.org/plugins/user-role-editor/" target="_blank" rel="noopener noreferrer">a plugin to fix roles and capabilities</a>. Sure enough, the admin user had no right to create posts or pages. When I tried to give those rights, I saw the following in my logs:</p>



<pre class="wp-block-preformatted">PHP message: WordPress database error Duplicate entry '0' for key 'PRIMARY' for query INSERT INTO `wp_usermeta` (`user_id`, `meta_key`, `meta_value`) VALUES (1, 'wp_capabilities', 'a:1:{s:13:\"administrator\";b:1;}') made by require_once('wp-admin/admin.php'), do_action('users_page_users-user-role-editor'), User_Role_Editor-&gt;edit_roles, Ure_Lib-&gt;editor, Ure_Lib-&gt;process_user_request, Ure_Lib-&gt;permissions_object_update, Ure_Lib-&gt;update_user, WP_User-&gt;add_role, update_user_meta, update_metadata, add_metadata</pre>



<p>(I have debug logs enabled in my development environment. If you&#8217;re in a production environment you might not see this.)</p>



<p>Why would a primary key be set to <code>0</code> you ask? A quick glance at the structure of the <code>wp_usermeta</code> table via <code>phpmyadmin</code> reveals that the primary key column had no <code>auto_increment</code> flag.</p>



<p>SQL <code>INSERT</code> statements from various plugins were inserting rows in various tables with the primary key being undefined (and therefore set to a default of <code>0</code>). Since primary keys have a unique constraint, attempting to do a second insert to the same table fails, causing all sorts of havoc.</p>



<p><strong>For some reason the <code>auto_increment</code> flag had not been preserved when I re-imported the SQL dump.</strong> Everything else seemed to be in order though. I did not investigate why this happened but decided to simply fix this.</p>



<h2 class="wp-block-heading">Coding is the solution</h2>



<p>By now I could see the front-end but was not able to login to the admin interface any more. Any <code>INSERT</code> query to the database, including those that store session information upon login, were failing. As I had quite a lot of tables, I decided not to do this manually, but to write a generic script.</p>



<p>As a side-note, the script needs to connect with the <code>NO_ZERO_DATE</code> SQL mode. WordPress uses a lot of <code>DATETIME</code> fields with a default value of <code>0000-00-00 00:00:00</code> and this script will be <strong>very unhappy</strong> if this mode is not set.</p>



<h3 class="wp-block-heading">Pseudocode</h3>



<pre class="wp-block-preformatted">for all tables in database
&nbsp;&nbsp;&nbsp; get the primary key column's name and type
&nbsp;&nbsp;&nbsp; get the next available primary key value
&nbsp;&nbsp;&nbsp; change the row with zero primary key so it has the next available primary key
&nbsp;&nbsp;&nbsp; set the auto_increment flag to the primary key column</pre>



<p><strong>Note: The above assumes that all the primary keys are numeric. YMMV.</strong></p>



<h3 class="wp-block-heading">What to do:</h3>



<p>Next is a PHP listing of the above solution. Here&#8217;s what to do:</p>



<ol class="wp-block-list">
<li><strong>Check to see that your issue is actually one of missing auto_increment flags.</strong> This script will only repair this particular error.</li>



<li><strong>Check to see that all your primary keys are numeric.</strong> There shouldn&#8217;t be an issue if some aren&#8217;t but you might have to hack the code manually or go update the structure of those tables via <code>phpmyadmin</code>.</li>



<li><strong>Change the host, dbname, username and password in the code to those that match your system.</strong></li>



<li><strong>Backup your database</strong> (I guess you already have a backup and that&#8217;s what caused the issue, but still, you want to be able to go back if something goes wrong when running this.) You are solely responsible for any damages including data loss from running this script. Don&#8217;t blame me for corrupting your data please. Read and understand the code first!</li>
</ol>



<h3 class="wp-block-heading">The PHP script</h3>



<p>Save this in a file, take another deep breath, and run it via the PHP CLI. I hope it solves your MySQL woes. Good luck buddy.</p>



<pre class="wp-block-preformatted">&lt;?php

// change these settings
$servername = 'localhost';
$username = 'dbuser';
$password = 'password';
$db = 'database';

// connect
$conn = new mysqli($servername, $username, $password);
try {
&nbsp;&nbsp; &nbsp;$conn = new PDO("mysql:host=$servername;dbname=$db", $username, $password, array(PDO::MYSQL_ATTR_INIT_COMMAND =&gt; 'SET sql_mode="NO_ZERO_DATE"') );
&nbsp;&nbsp; &nbsp;$conn-&gt;setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION );
&nbsp;&nbsp; &nbsp;echo "Connected successfully";
} catch(PDOException $e) {
&nbsp;&nbsp; &nbsp;exit( "Connection failed: " . $e-&gt;getMessage() );
}

// get all table names
$stm = $conn-&gt;prepare('SHOW TABLES');
$stm-&gt;execute();
$table_names = array();
foreach ( $stm-&gt;fetchAll() as $row ) {
&nbsp;&nbsp; &nbsp;$table_names[] = $row[0];
}

// for all tables
foreach ( $table_names as $table_name ) {
&nbsp;&nbsp; &nbsp;echo "\nRepairing table $table_name...\n";

&nbsp;&nbsp; &nbsp;// get the primary key name
&nbsp;&nbsp; &nbsp;$stm = $conn-&gt;prepare( "show keys from $table_name where Key_name = 'PRIMARY'" );
&nbsp;&nbsp; &nbsp;$stm-&gt;execute();
&nbsp;&nbsp; &nbsp;$key_name = $stm-&gt;fetch()['Column_name'];

&nbsp;&nbsp; &nbsp;// get the primary key type
&nbsp;&nbsp; &nbsp;$stm = $conn-&gt;prepare( "show fields from $table_name where Field = '$key_name'" );
&nbsp;&nbsp; &nbsp;$stm-&gt;execute();
&nbsp;&nbsp; &nbsp;$key_type = $stm-&gt;fetch()['Type'];

&nbsp;&nbsp; &nbsp;// if there is a primary key
&nbsp;&nbsp; &nbsp;if ($key_name) {
&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;echo "Primary key is $key_name\n";

&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;try {
&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;// if auto_increment was missing there might be a row with key=0 . compute the next available primary key
&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;$sql = "select (ifnull( max($key_name), 0)+1) as next_id from $table_name";
&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;$stm = $conn-&gt;prepare( $sql );
&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;echo "$sql\n";
&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;$stm-&gt;execute();
&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;$next_id = $stm-&gt;fetch()['next_id'];

&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;// give a sane primary key to a row that has key = 0 if it exists
&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;$sql = "update $table_name set $key_name = $next_id where $key_name = 0";
&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;echo "$sql\n";
&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;$stm = $conn-&gt;prepare( $sql );
&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;$stm-&gt;execute();

&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;// set auto_increment to the primary key
&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;$sql = "alter table $table_name modify column $key_name $key_type auto_increment";
&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;echo "$sql\n";
&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;$stm = $conn-&gt;prepare( $sql );
&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;$stm-&gt;execute();

&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;} catch (PDOException $e) {
&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;echo "\n\nQuery: $sql\nError:" . $e-&gt;getMessage() . "\n\n";
&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;}
&nbsp;&nbsp; &nbsp;} else {
&nbsp;&nbsp; &nbsp;&nbsp;&nbsp; &nbsp;echo "No primary key found for table $table_name.\n";
&nbsp;&nbsp; &nbsp;}
}
echo "\n\nFinished\n";
$conn = null;

</pre>



<p>If I have helped you get your site up and running, you can donate a few Satoshis at: <a href="bitcoin:bc1qjkgp8u2jwy2n9k20avjweuy7etsjfpfplvf99q">bc1qjkgp8u2jwy2n9k20avjweuy7etsjfpfplvf99q</a></p>
<p>The post <a href="https://www.alexgeorgiou.gr/repair-auto_increment-primary-key-wordpress-mysql/">auto_increment flag repair on primary keys of a WordPress MySQL database</a> appeared first on <a href="https://www.alexgeorgiou.gr">Alexandros Georgiou</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.alexgeorgiou.gr/repair-auto_increment-primary-key-wordpress-mysql/feed/</wfw:commentRss>
			<slash:comments>41</slash:comments>
		
		
			</item>
		<item>
		<title>🗨 Dismissible notices that persist when refreshing the WordPress admin screens</title>
		<link>https://www.alexgeorgiou.gr/persistently-dismissible-notices-wordpress/</link>
					<comments>https://www.alexgeorgiou.gr/persistently-dismissible-notices-wordpress/#comments</comments>
		
		<dc:creator><![CDATA[alexg]]></dc:creator>
		<pubDate>Mon, 28 Nov 2016 12:15:47 +0000</pubDate>
				<category><![CDATA[Blog]]></category>
		<category><![CDATA[admin screens]]></category>
		<category><![CDATA[database]]></category>
		<category><![CDATA[development]]></category>
		<category><![CDATA[dismiss]]></category>
		<category><![CDATA[dismissable]]></category>
		<category><![CDATA[dismissible]]></category>
		<category><![CDATA[error]]></category>
		<category><![CDATA[error handling]]></category>
		<category><![CDATA[error reporting]]></category>
		<category><![CDATA[notification]]></category>
		<category><![CDATA[popup]]></category>
		<category><![CDATA[wordpress]]></category>
		<guid isPermaLink="false">http://www.alexgeorgiou.gr/?p=154</guid>

					<description><![CDATA[<p>In this article we will go through some code that I like to use to make dismissible  notices where the dismissal persists between page refreshes in the WordPress administration screens.</p>
<p>The post <a href="https://www.alexgeorgiou.gr/persistently-dismissible-notices-wordpress/">🗨 Dismissible notices that persist when refreshing the WordPress admin screens</a> appeared first on <a href="https://www.alexgeorgiou.gr">Alexandros Georgiou</a>.</p>
]]></description>
										<content:encoded><![CDATA[<p><em>In this article we will go through some code that I like to use to make dismissible  notices where the dismissal persists between page refreshes in the WordPress administration screens.</em></p>
<p>&nbsp;</p>
<h2><img fetchpriority="high" decoding="async" class="aligncenter wp-image-163 size-full" title="Dismissible notices that persist when refreshing the WordPress admin screens" src="http://www.alexgeorgiou.gr/wp-content/uploads/2016/11/wp-notices-1.png" alt="Make dismissible admin notices where the dismissal persists between page refreshes in the WordPress administration screens." width="963" height="480" srcset="https://www.alexgeorgiou.gr/wp-content/uploads/2016/11/wp-notices-1.png 963w, https://www.alexgeorgiou.gr/wp-content/uploads/2016/11/wp-notices-1-300x150.png 300w, https://www.alexgeorgiou.gr/wp-content/uploads/2016/11/wp-notices-1-768x383.png 768w, https://www.alexgeorgiou.gr/wp-content/uploads/2016/11/wp-notices-1-800x400.png 800w" sizes="(max-width: 599px) calc(100vw - 50px), (max-width: 767px) calc(100vw - 70px), (max-width: 991px) 429px, (max-width: 1199px) 637px, 354px" /></h2>
<h2>Some things to note about dismissible notices</h2>
<p>Don&#8217;t you just hate how this functionality has <strong>still</strong> not made it into core? <a href="https://make.wordpress.org/core/2015/04/23/spinners-and-dismissible-admin-notices-in-4-2/" target="_blank" rel="noopener noreferrer">Legend has it</a> that dismissible notices were introduced back in WordPress 4.2, but that only means that when you click the little &#8220;x&#8221; at the top right, the notice box becomes hidden. The way to enable this is that you add a <code>.is-dismissible</code> class into your notice&#8217;s markup:</p>
<pre>&lt;div class="notice notice-warn is-dismissible"&gt;your message here&lt;/div&gt;</pre>
<p>And that hides the box. Until the next page refresh. This is not only annoying, it also goes <a href="https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/" target="_blank" rel="noopener noreferrer">against the WordPress.org guidelines</a>:</p>
<blockquote><p>Upgrade prompts, notices, and alerts should be limited in scope and used sparingly or only on the plugin’s setting page. Any site wide notices or embedded dashboard widgets <em>must</em> be dismissible. Error messages and alerts should include information on how to resolve the situation, and remove themselves when completed.</p></blockquote>
<p>So for those of us aspiring to be hosted on <strong>WordPress.org</strong> it&#8217;s a necessity, rather than a luxury feature, to be able to let the user dismiss notices persistently.</p>
<h2>Requirements analysis</h2>
<p>WordPress tradition has it that you <strong>store an option in the database</strong>. That way you know not to show the same notice again. But this is something that you have to do over and over again, so it&#8217;s exactly the kind of thing that you want to <strong>include in all your code as a library</strong>. You want something that can be <strong>easily called from wherever</strong>, so the tried and true singleton pattern will do. You need the ability to be able to <strong>assign a slug to your notice</strong>, so you know how to store it in the options table. But you also want to be able to <strong>show the occasional non-dismissible notice</strong>. Finally, let&#8217;s be nice and <strong>clean up after ourselves</strong>. The options will be deleted when our plugin is uninstalled.</p>
<h2>Let&#8217;s do this!</h2>
<h3>Writing code with class</h3>
<p>We need something that can be included from our plugin (or theme). Let&#8217;s make a singleton class that can hold arrays of different types of notices (success, info, warn, error):</p>
<pre>&lt;?php

// don't load directly
defined( 'ABSPATH' ) || die( '-1' );

if ( ! class_exists( 'MyPlugin_Admin_Notices' ) ) {

    class MyPlugin_Admin_Notices {

        private static $_instance;
        private $admin_notices;
        const TYPES = 'error,warning,info,success';

        private function __construct() {
            $this-&gt;admin_notices = new stdClass();
            foreach ( explode( ',', self::TYPES ) as $type ) {
                $this-&gt;admin_notices-&gt;{$type} = array();
            }
        }

        public static function get_instance() {
            if ( ! ( self::$_instance instanceof self ) ) {
                self::$_instance = new self();
            }
            return self::$_instance;
        }
    }
}

MyPlugin_Admin_Notices::get_instance();</pre>
<p>Nice. This is a useful bucket where we can throw in notices. They will be retrieved for rendering only later, when we will add code to the <code>admin_notices</code> action.</p>
<h3>An API for entering notices from our code</h3>
<p>For now, let&#8217;s add in some methods to our class for populating our notice arrays:</p>
<pre>public function error( $message, $dismiss_option = false ) {
    $this-&gt;notice( 'error', $message, $dismiss_option );
}

public function warning( $message, $dismiss_option = false ) {
    $this-&gt;notice( 'warning', $message, $dismiss_option );
}

public function success( $message, $dismiss_option = false ) {
    $this-&gt;notice( 'success', $message, $dismiss_option );
}

public function info( $message, $dismiss_option = false ) {
    $this-&gt;notice( 'info', $message, $dismiss_option );
}

private function notice( $type, $message, $dismiss_option ) {
    $notice = new stdClass();
    $notice-&gt;message = $message;
    $notice-&gt;dismiss_option = $dismiss_option;

    $this-&gt;admin_notices-&gt;{$type}[] = $notice;
}</pre>
<p><strong>Notice the visibility of the functions.</strong> The four public functions that comprise our API all defer to the private function that does the data collection. We&#8217;ll let the user enter a message string, and optionally give a slug with the <code>dismiss_option</code> parameter. If set, this will be part of the database option&#8217;s slug.</p>
<h3>Writing the markup</h3>
<p>We have a mechanism for adding notices into our memory, now let&#8217;s dump them to the admin area as markup. This will happen on the <code>admin_notices</code> action, so first add this to the constructor:</p>
<pre>add_action( 'admin_notices', array( &amp;$this, 'action_admin_notices' ) );</pre>
<p>and then let&#8217;s actually write the markup. I&#8217;ve chosen to show notices in decreasing levels of severity, hence the nested loops:</p>
<pre>public function action_admin_notices() {
    foreach ( explode( ',', self::TYPES ) as $type ) {
        foreach ( $this-&gt;admin_notices-&gt;{$type} as $admin_notice ) {

            $dismiss_url = add_query_arg( array(
                'myplugin_dismiss' =&gt; $admin_notice-&gt;dismiss_option
            ), admin_url() );

            if ( ! get_option( "myplugin_dismissed_{$admin_notice-&gt;dismiss_option}" ) ) {
                ?&gt;&lt;div
                    class="notice myplugin-notice notice-&lt;?php echo $type;

                    if ( $admin_notice-&gt;dismiss_option ) {
                        echo ' is-dismissible" data-dismiss-url="' . esc_url( $dismiss_url );
                    } ?&gt;"&gt;

                    &lt;h2&gt;&lt;?php echo "My Plugin $type"; ?&gt;&lt;/h2&gt;
                    &lt;p&gt;&lt;?php echo $admin_notice-&gt;message; ?&gt;&lt;/p&gt;

                &lt;/div&gt;&lt;?php
            }
        }
    }
}</pre>
<p><strong>Note how we show a notice only if the DB does not have a corresponding option</strong> named <code>myplugin_dismissed_$dismiss_option</code>. We&#8217;ll need a mechanism to set that DB option when the user clicks to dismiss the notice. We will do this from JavaScript, making a call with that option name as a GET parameter, so that we know to set the correct database option.</p>
<h3>Notifying the backend from the admin front</h3>
<p>When a notice is dismissible, we output a <code>data-dismiss-url</code> attribute in the HTML. We&#8217;ll use that from JavaScript to make a call to that URL:</p>
<pre>/**
 * Admin code for dismissing notifications.
 *
 */
(function( $ ) {
    'use strict';
    $( function() {
        $( '.myplugin-notice' ).on( 'click', '.notice-dismiss', function( event, el ) {
            var $notice = $(this).parent('.notice.is-dismissible');
            var dismiss_url = $notice.attr('data-dismiss-url');
            if ( dismiss_url ) {
                $.get( dismiss_url );
            }
        });
    } );
})( jQuery );</pre>
<p>Pretty standard stuff. When a dismissible notice from our plugin is clicked on its <code>.notice-dismiss</code>, get the <code>dismiss_url</code> and call it. I guess I could have used a fancy framework like <a href="http://vanilla-js.com/" target="_blank" rel="noopener noreferrer">VanillaJS</a> for this, but I chose plain old jQuery instead :-p</p>
<p>Don&#8217;t forget the usual enqueue script shenanigans:</p>
<pre>public function action_admin_enqueue_scripts() {
    wp_enqueue_script( 'jquery' );
    wp_enqueue_script(
        'myplugin-notify',
        plugins_url( 'assets/scripts/myplugin-notify.js', __FILE__ ),
        array( 'jquery' )
    );
}</pre>
<p>and add this to our constructor so the enqueuing will actually happen:</p>
<pre>add_action( 'admin_enqueue_scripts', array( &amp;$this, 'action_admin_enqueue_scripts' ) );</pre>
<p>Perfect. Our little piece of JavaScript code now lives in the WordPress admin screens.</p>
<h3>Setting the DB option</h3>
<p>Now we&#8217;ll just need to catch the request on the PHP side and set a database option. Let&#8217;s do this on the <code>admin_init</code> action:</p>
<pre>add_action( 'admin_init', array( &amp;$this, 'action_admin_init' ) );</pre>
<p>&#8230;and this is the code that will set an option in the database. Note how the name is constructed from the GET parameter in the request URL.</p>
<pre>public function action_admin_init() {
    $dismiss_option = filter_input( INPUT_GET, 'myplugin_dismiss', FILTER_SANITIZE_STRING );
    if ( is_string( $dismiss_option ) ) {
        update_option( "myplugin_dismissed_$dismiss_option", true );
        wp_die();
    }
}</pre>
<p>Once the option is set, we can just let WordPress die. No need to return the admin interface to the browser, this is just a background AJAX call.</p>
<h3>Cleaning up after ourselves</h3>
<p>Unfortunately, even in this day and age, many developers don&#8217;t think it&#8217;s important to clean up the trash they leave in the poor user&#8217;s database. This is even more infuriating when you consider that it rarely takes more than a couple of lines of code. Don&#8217;t be that guy. Create an <code>uninstall.php</code> file with the following content.</p>
<pre>&lt;?php
if ( defined( 'WP_UNINSTALL_PLUGIN' ) ) {
    global $wpdb;
    $wpdb-&gt;query( 'DELETE FROM wp_options WHERE option_name LIKE "myplugin_dismissed_%";' );
}</pre>
<p>We can exploit the fact that we know the common prefix of all the dismissal DB options, and thus we can delete them all in one go, circumventing the <code><a href="https://codex.wordpress.org/Function_Reference/delete_option" target="_blank" rel="noopener noreferrer">delete_option</a>()</code> function altogether:</p>
<h3>Expecting the unexpected</h3>
<p><strong>If you put together all of the above you already have a nice way to show persistent dismissible admin notices.</strong> But why stop there? We can hack away some more and make sure that any runtime errors in our code show up as notices too.</p>
<p>Imagine a clueless user sees your theme not working. Which do you prefer? To have to explain to them how to enable debugging and how to find and send you the logs from the wordpress unix directory on the host? Or do you simply ask them to copy the error on the screen and send it to you via email?</p>
<p>Observe this little hack that takes advantage of the <a href="http://php.net/manual/en/function.set-error-handler.php" target="_blank" rel="noopener noreferrer">PHP error reporting mechanism</a>:</p>
<pre>public static function error_handler( $errno, $errstr, $errfile, $errline, $errcontext ) {
    if ( ! ( error_reporting() &amp; $errno ) ) {
        // This error code is not included in error_reporting
        return;
    }

    $message = "errstr: $errstr, errfile: $errfile, errline: $errline, PHP: " . PHP_VERSION . " OS: " . PHP_OS;

    $self = self::get_instance();
    switch ($errno) {
        case E_USER_ERROR:
            $self-&gt;error( $message );
            break;

        case E_USER_WARNING:
            $self-&gt;warning( $message );
            break;

        case E_USER_NOTICE:
        default:
            $self-&gt;notice( $message );
            break;
    }

    // write to wp-content/debug.log if logging enabled
    error_log( $message );

    // Don't execute PHP internal error handler
    return true;
}</pre>
<p>Notice how this method is static, since we&#8217;d like to be able to call it from both dynamic and static contexts. We can now hook it up to PHP&#8217;s error reporting like so:</p>
<pre><a href="http://php.net/manual/en/function.set-error-handler.php" target="_blank" rel="noopener noreferrer">set_error_handler</a>( array( 'MyPlugin_Admin_Notices', 'error_handler' ) );</pre>
<p>and after your code ends, unhook it so as not to interfere with the WordPress core or other components:</p>
<pre><a href="http://php.net/manual/en/function.restore-error-handler.php" target="_blank" rel="noopener noreferrer">restore_error_handler</a>();</pre>
<p>This will pop your error handler from a stack and return to whatever error handling mechanism was there before. <strong>Make a habit of surrounding your function bodies with these two lines</strong>, and you will know that whatever happens, you&#8217;ll at least get a visible user-friendly <strong>and</strong> developer-friendly error message on the admin screens.</p>
<h2>Putting it all together</h2>
<h3>JS</h3>
<pre>/**
 * Admin code for dismissing notifications.
 *
 */
(function( $ ) {
    'use strict';
    $( function() {
        $( '.myplugin-notice' ).on( 'click', '.notice-dismiss', function( event, el ) {

            var $notice = $(this).parent('.notice.is-dismissible');
            var dismiss_url = $notice.attr('data-dismiss-url');
            if ( dismiss_url ) {
                $.get( dismiss_url );
            }
        });
    } );
})( jQuery );</pre>
<h3>PHP</h3>
<pre>&lt;?php
if ( defined( 'WP_UNINSTALL_PLUGIN' ) ) {
    global $wpdb;
    $wpdb-&gt;query( 'DELETE FROM wp_options WHERE option_name LIKE "/* @echo slugus */_dismissed_%";' );
}</pre>
<pre>&lt;?php
// don't load directly
defined( 'ABSPATH' ) || die( '-1' );

if ( ! class_exists( 'MyPlugin_Admin_Notices' ) ) {

    class MyPlugin_Admin_Notices {

        private static $_instance;
        private $admin_notices;
        const TYPES = 'error,warning,info,success';

        private function __construct() {
            $this-&gt;admin_notices = new stdClass();
            foreach ( explode( ',', self::TYPES ) as $type ) {
                $this-&gt;admin_notices-&gt;{$type} = array();
            }
            add_action( 'admin_init', array( &amp;$this, 'action_admin_init' ) );
            add_action( 'admin_notices', array( &amp;$this, 'action_admin_notices' ) );
            add_action( 'admin_enqueue_scripts', array( &amp;$this, 'action_admin_enqueue_scripts' ) );
        }

        public static function get_instance() {
            if ( ! ( self::$_instance instanceof self ) ) {
                self::$_instance = new self();
            }
            return self::$_instance;
        }

        public function action_admin_init() {
            $dismiss_option = filter_input( INPUT_GET, 'myplugin_dismiss', FILTER_SANITIZE_STRING );
            if ( is_string( $dismiss_option ) ) {
                update_option( "myplugin_dismissed_$dismiss_option", true );
                wp_die();
            }
        }

        public function action_admin_enqueue_scripts() {
            wp_enqueue_script( 'jquery' );
            wp_enqueue_script(
                'myplugin-notify',
                plugins_url( 'assets/scripts/myplugin-notify.js', __FILE__ ),
                array( 'jquery' )
            );
        }

        public function action_admin_notices() {
            foreach ( explode( ',', self::TYPES ) as $type ) {
                foreach ( $this-&gt;admin_notices-&gt;{$type} as $admin_notice ) {

                    $dismiss_url = add_query_arg( array(
                        'myplugin_dismiss' =&gt; $admin_notice-&gt;dismiss_option
                    ), admin_url() );

                    if ( ! get_option( "myplugin_dismissed_{$admin_notice-&gt;dismiss_option}" ) ) {
                        ?&gt;&lt;div
                            class="notice myplugin-notice notice-&lt;?php echo $type;

                            if ( $admin_notice-&gt;dismiss_option ) {
                                echo ' is-dismissible" data-dismiss-url="' . esc_url( $dismiss_url );
                            } ?&gt;"&gt;

                            &lt;h2&gt;&lt;?php echo "My Plugin $type"; ?&gt;&lt;/h2&gt;
                            &lt;p&gt;&lt;?php echo $admin_notice-&gt;message; ?&gt;&lt;/p&gt;

                        &lt;/div&gt;&lt;?php
                    }
                }
            }
        }

        public function error( $message, $dismiss_option = false ) {
            $this-&gt;notice( 'error', $message, $dismiss_option );
        }

        public function warning( $message, $dismiss_option = false ) {
            $this-&gt;notice( 'warning', $message, $dismiss_option );
        }

        public function success( $message, $dismiss_option = false ) {
            $this-&gt;notice( 'success', $message, $dismiss_option );
        }

        public function info( $message, $dismiss_option = false ) {
            $this-&gt;notice( 'info', $message, $dismiss_option );
        }

        private function notice( $type, $message, $dismiss_option ) {
            $notice = new stdClass();
            $notice-&gt;message = $message;
            $notice-&gt;dismiss_option = $dismiss_option;

            $this-&gt;admin_notices-&gt;{$type}[] = $notice;
        }

	public static function error_handler( $errno, $errstr, $errfile, $errline, $errcontext ) {
		if ( ! ( error_reporting() &amp; $errno ) ) {
			// This error code is not included in error_reporting
			return;
		}

		$message = "errstr: $errstr, errfile: $errfile, errline: $errline, PHP: " . PHP_VERSION . " OS: " . PHP_OS;

		$self = self::get_instance();

		switch ($errno) {
			case E_USER_ERROR:
				$self-&gt;error( $message );
				break;

			case E_USER_WARNING:
				$self-&gt;warning( $message );
				break;

			case E_USER_NOTICE:
			default:
				$self-&gt;notice( $message );
				break;
		}

		// write to wp-content/debug.log if logging enabled
		error_log( $message );

		// Don't execute PHP internal error handler
		return true;
	}
    }
}

MyPlugin_Admin_Notices::get_instance();</pre>
<h2>Thanks for reading</h2>
<p>Please comment on what you liked or didn&#8217;t like in my code. What would you have done differently? Can you spot any bugs?</p>
<p>Until next time. Dismissed!</p>
<p>The post <a href="https://www.alexgeorgiou.gr/persistently-dismissible-notices-wordpress/">🗨 Dismissible notices that persist when refreshing the WordPress admin screens</a> appeared first on <a href="https://www.alexgeorgiou.gr">Alexandros Georgiou</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.alexgeorgiou.gr/persistently-dismissible-notices-wordpress/feed/</wfw:commentRss>
			<slash:comments>8</slash:comments>
		
		
			</item>
		<item>
		<title>🖴 Poor man&#8217;s guide to backup WordPress droplets</title>
		<link>https://www.alexgeorgiou.gr/poor-mans-guide-backup-wordpress-droplets/</link>
					<comments>https://www.alexgeorgiou.gr/poor-mans-guide-backup-wordpress-droplets/#comments</comments>
		
		<dc:creator><![CDATA[alexg]]></dc:creator>
		<pubDate>Wed, 22 Jun 2016 13:25:03 +0000</pubDate>
				<category><![CDATA[Blog]]></category>
		<category><![CDATA[backup]]></category>
		<category><![CDATA[cron]]></category>
		<category><![CDATA[database]]></category>
		<category><![CDATA[digital ocean]]></category>
		<category><![CDATA[mysqldump]]></category>
		<category><![CDATA[SQL]]></category>
		<category><![CDATA[wordpress]]></category>
		<guid isPermaLink="false">http://www.alexgeorgiou.gr/?p=97</guid>

					<description><![CDATA[<p>In this article I aim to show you how I backup my WordPress sites on my Digital Ocean droplet.</p>
<p>The post <a href="https://www.alexgeorgiou.gr/poor-mans-guide-backup-wordpress-droplets/">🖴 Poor man&#8217;s guide to backup WordPress droplets</a> appeared first on <a href="https://www.alexgeorgiou.gr">Alexandros Georgiou</a>.</p>
]]></description>
										<content:encoded><![CDATA[<p>In this article I aim to show you how I backup my WordPress sites on my Digital Ocean droplet. The same method should apply to many if not all VPS services.</p>
<h1>Digital Ocean backup feature</h1>
<p>Perhaps one of the reasons <a href="https://m.do.co/c/44d4d2184573">Digital Ocean</a> is currently such a popular VPS provider is its low-price affordable plans. Its $5/month plan makes perfect sense for small, low-traffic sites, especially if you bundle together a bunch of them on the same server.</p>
<p>Digital Ocean lets you take full <strong>weekly backups</strong> of your server for a modest price:</p>
<p><div id="attachment_98" style="width: 845px" class="wp-caption aligncenter"><img loading="lazy" decoding="async" aria-describedby="caption-attachment-98" class="wp-image-98 size-full" src="http://www.alexgeorgiou.gr/wp-content/uploads/2016/06/digitalocean-backups.png" alt="Enabling backups on your Digitial Ocean droplet" width="835" height="271" srcset="https://www.alexgeorgiou.gr/wp-content/uploads/2016/06/digitalocean-backups.png 835w, https://www.alexgeorgiou.gr/wp-content/uploads/2016/06/digitalocean-backups-300x97.png 300w, https://www.alexgeorgiou.gr/wp-content/uploads/2016/06/digitalocean-backups-768x249.png 768w" sizes="auto, (max-width: 599px) calc(100vw - 50px), (max-width: 767px) calc(100vw - 70px), (max-width: 991px) 429px, (max-width: 1199px) 637px, 354px" /><p id="caption-attachment-98" class="wp-caption-text">Digital Ocean can help you to weekly backup WordPress or any other type of sites at the server level.</p></div></p>
<p>These are backups of the entire system that you can later restore.</p>
<p>You can also take <strong>snapshots</strong> of your VPS, but you must first shutdown your server. This makes sense if you have setup a large system with replication and load balancers, but not as much in the low-cost setup where one small droplet serves a number of sites. Additionally, weekly backups seems to me as not enough granularity. Especially if your sites include e-shops or other sites where users frequently perform transactions, you&#8217;ll want to have frequent backups, perhaps daily.</p>
<h1>Do it yourself</h1>
<p>Fortunately, with only a few lines of code, you can roll your own backup system that will perform a full WordPress database backup in any way you like. Of course you can read on even if you have your server on another VPS service, or if you want to backup anything that uses a MySQL database, not just a WordPress site.</p>
<p>I opted not to go for any online storage solutions. The method I present here is what I chose to do based on my requirement of a low cost, fully custom solution that I can tweak any way I like. <strong>It requires a second machine where the backups will be kept.</strong> This machine lives at home and can be any old piece of hardware that&#8217;s lying around as long as it can connect to the internet.</p>
<p>The plan is to have the backup machine tell the VPS at regular intervals to take backups of the WordPress databases, then download these backups and store them by date/time. We will achieve this using <code>cron</code>, <code>mysqldump</code>, and <code>rcp</code>.</p>
<h2>Set up passwordless remote access</h2>
<p>This is the first thing you need to do. I will not go into detail because there&#8217;s a ton of articles on this (and really, you should have already done this). <a href="https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu-14-04">Here&#8217;s the official guide</a>. The article says it&#8217;s for Ubuntu servers but the same process applies to Debian and friends.</p>
<p>Long story short:</p>
<ol>
<li>Go to your local machine, and do
<pre class="code-pre custom_prefix"><code>ssh-keygen</code></pre>
<p>to <strong>generate a key pair</strong> if you don&#8217;t already have one.</li>
<li>Copy your <strong>public</strong> key to the droplet with
<pre class="code-pre custom_prefix"><code>ssh-copy-id <span class="highlight">user</span>@<span class="highlight">droplet</span></code></pre>
<p>replacing <code>user</code> and <code>droplet</code> as needed.</li>
</ol>
<h2>Set up a &#8220;backup&#8221; user</h2>
<p>Not strictly necessary, but for extra security it would be a good idea to not use the master database password that WordPress uses to create your WordPress backups. You can setup a read-only user on your MySQL database that has read access only to the databases you want to backup. Here&#8217;s an example of what to type into the MySQL command line interface. (You can also do this via phpmysql if you have it installed and configured.)</p>
<p>First, connect to your droplet with ssh</p>
<pre>ssh user@droplet</pre>
<p>where <code>user</code> is your actual user name and <code>droplet</code> is your server&#8217;s IP address.</p>
<p>Make sure the MySQL CLI is installed. We&#8217;ll also need mysqldump which is included in the same package. So, do this:</p>
<pre>sudo apt-get install mysql-client</pre>
<p>Once the package is installed, fire up the CLI with:</p>
<pre>mysql -u root -p</pre>
<p>On the next line you will be asked for the MySQL root password. (This is done because it&#8217;s not secure to type passwords on the shell&#8217;s command line.)</p>
<p>Once you&#8217;re in, you should be seeing the <code>mysql&gt;</code> prompt. First, create a user and give it a password. I called my backup user <code>wp_bu</code>:</p>
<pre>CREATE USER 'wp_bu'@'localhost' IDENTIFIED BY 'PASSWORD';</pre>
<p><em>Pro tip: For maximum security, don&#8217;t use &#8216;PASSWORD&#8217; as your password. This database user will have read access to all the data on all your websites. Choose a strong password, like you did with the root DB password and WordPress passwords.</em></p>
<p>Then you will need to choose which databases the user can read. If you&#8217;re not sure which databases you have, type this:</p>
<pre>show databases;</pre>
<p>Ignore the <code>mysql</code> database and other metadata such as <code>information_schema</code> or <code>performace_schema</code>. The other databases listed should correspond to all your sites. Let&#8217;s say you want to be able to backup the database with the name <code>wordpress_db</code>. Type this in:</p>
<pre class="wiki">GRANT SELECT, LOCK TABLES ON wordpress_db.* TO 'wp_bu'@'localhost';</pre>
<p>Select and lock tables are the minimum access rights that you need to dump a database to disk. Repeat this line for every database you need to backup. When finished exit the CLI:</p>
<pre>exit</pre>
<h2>The cron job</h2>
<p>We&#8217;re now ready to setup a cron that will create the backups and copy them somewhere safe, hopefully. I assume you have a low-power machine somewhere in your house that you already leave on 24/7. This would typically be your torrent box, media center, bitcoin wallet, NAS, git upstream repo, <a href="https://trac.edgewall.org/">trac</a> server, etc. It will now also be the backup server for your sites.</p>
<p>Log in to your backup machine and do a <code>crontab -e</code> to edit your crontab. We&#8217;ll need to do three things:</p>
<ol>
<li>Create the backup</li>
<li>Copy the backup</li>
<li>Delete the backup</li>
</ol>
<p>Here&#8217;s an example of how to do this every day at 3 a.m. for a database named <code>wordpress_db</code>:</p>
<h3>Create the DB backup</h3>
<p>This is the most important part of our custom backup solution:</p>
<pre>0 3 * * * ssh -t user@droplet "nice -n 19 mysqldump -u wp_bu -pPASSWORD wordpress_db |gzip -9 &gt;/home/user/backups/wordpress_db-`date --rfc-3339=date`.sql.gz"</pre>
<p>This crontab entry says that at 3 a.m. every day, your local machine is to use <code>ssh</code> to execute the command in double quotes as the user &#8220;<code>user</code>&#8221; on your server, where &#8220;<code>droplet</code>&#8221; is your server&#8217;s IP. The command itself uses <code>mysqldump</code> to connect to the local database with the <code>wp_bu</code> DB user we created earlier, and dumps the <code>wordpress_db</code> database to a gzipped file. The file name contains given the current date. This whole process is run with the <code>nice -n 19</code> prefix, to make sure that the process is not given priority over our webserver.</p>
<h3>Create the files backup</h3>
<p>I&#8217;ll allow two minutes which should be plenty of time for my database to be dumped to file, but if you have a really large database, check to make sure</p>
<p>Now we want to also take a snapshot of the file contents of the website. Assuming that your WordPress install is at <code>/var/www/wordpress</code>:</p>
<pre class="wiki">2 3 * * * ssh -t user@droplet "nice -n 19 zip -9r backups/wp-`/bin/date --rfc-3339=date`.zip /var/www/wordpress/wp-content/* "</pre>
<h3>Copy the two backup files</h3>
<p>After a few more minutes, I&#8217;ll use <code>rcp</code> to pull the files from the server.</p>
<pre>10 3 * * * rcp user@droplet:/home/user/backups/* /path/to/droplet/backups/</pre>
<p>This cron entry will again use the passwordless access that we have setup to copy the file to your local path.</p>
<h3>Deleting the backup</h3>
<p>It is a very very bad idea to keep backups on the same server, not only because you&#8217;re filling up your precious VPS space, but also for security reasons. Let&#8217;s wait another minute or so for rcp to finish (your mileage may vary), and then we&#8217;ll shred the files:</p>
<pre>20 3 * * * ssh -t user@droplet "shred -u /home/user/backups/*"</pre>
<p>This last cron entry will keep our backups directory clean on the server. You could just use <code>rm</code> to delete the files, but that&#8217;s way too non-paranoid for my tastes. (Also, those are <strong>rented</strong> SSDs, so no need to worry <strong>too</strong> much about wearing them out.)</p>
<h2>Recovering</h2>
<p>If something goes horribly wrong, you can import the latest <code>.sql.gz</code> file using <code>phpmyadmin</code> or simply using the <code>mysql</code> CLI. And just unzip the <code>.zip</code> file into a new <code>wp-content</code> dir. Make sure the files are owned by <code>www-data</code>, or whatever your server runs as, and you should be good to go.</p>
<p>In fact I recommend that you test restoring the backup on a local machine <strong>before</strong> something goes wrong. Really. Go test it now!</p>
<h1>Lean backups</h1>
<p>You&#8217;ll notice that these backups are <strong>not incremental</strong>. Assuming you&#8217;re using this backup method on small sites, the backup files shouldn&#8217;t get too large. But in any case you might want to make sure that your WordPress databases are not full of useless stuff. There are plugins out there that help you clean up databases from old edit revisions which take up space, as well as other useless data. I use <a href="https://wordpress.org/plugins/wp-optimize/">wp-optimize</a> every now and then. This also helps save space on your VPS.</p>
<h1>Shameless referral link plug</h1>
<p>If by any chance this article has convinced you to sign up to Digital Ocean (and why not, it&#8217;s a great service), please use my referral link <a href="https://m.do.co/c/44d4d2184573">https://m.do.co/c/44d4d2184573</a>. You&#8217;ll instantly get $10 credit and if you keep using it I might get something out of it too. Thanks!</p>
<p>The post <a href="https://www.alexgeorgiou.gr/poor-mans-guide-backup-wordpress-droplets/">🖴 Poor man&#8217;s guide to backup WordPress droplets</a> appeared first on <a href="https://www.alexgeorgiou.gr">Alexandros Georgiou</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.alexgeorgiou.gr/poor-mans-guide-backup-wordpress-droplets/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
	</channel>
</rss>
