<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="../assets/xml/rss.xsl" media="all"?><rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>blog.tedder.dev (Posts about ci-cd)</title><link>https://blog.tedder.dev/</link><description></description><atom:link href="https://blog.tedder.dev/categories/ci-cd.xml" rel="self" type="application/rss+xml"></atom:link><language>en</language><copyright>Contents © 2026 &lt;a href="mailto:ted@tedder.me"&gt;tedder&lt;/a&gt; </copyright><lastBuildDate>Sun, 12 Jul 2026 22:51:24 GMT</lastBuildDate><generator>Nikola (getnikola.com)</generator><docs>http://blogs.law.harvard.edu/tech/rss</docs><item><title>Automating Nikola blog deployments with GitHub Actions</title><link>https://blog.tedder.dev/posts/automating-nikola-deployments-github-actions/?utm_source=/categories/ci-cd.xml&amp;utm_medium=nikola_feed&amp;utm_campaign=rss_feed</link><dc:creator>tedder</dc:creator><description>&lt;p&gt;Building a Nikola static site is fast. Pushing to S3 is fast. But running them manually every time you publish a post is tedious. GitHub Actions automates both in a few minutes.&lt;/p&gt;
&lt;h3&gt;The setup we built&lt;/h3&gt;
&lt;p&gt;A workflow that:
1. Listens for pushes to &lt;code&gt;main&lt;/code&gt;
2. Installs Nikola and dependencies
3. Builds the entire site
4. Pushes the output to S3&lt;/p&gt;
&lt;p&gt;Every post goes live the moment you push.&lt;/p&gt;
&lt;h3&gt;Create limited AWS credentials&lt;/h3&gt;
&lt;p&gt;First, create an &lt;a href="https://docs.aws.amazon.com/iam/latest/userguide/id_users.html"&gt;IAM user&lt;/a&gt; with S3-only access to your bucket. This follows the &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html"&gt;principle of least privilege&lt;/a&gt;:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;aws&lt;span class="w"&gt; &lt;/span&gt;iam&lt;span class="w"&gt; &lt;/span&gt;create-user&lt;span class="w"&gt; &lt;/span&gt;--user-name&lt;span class="w"&gt; &lt;/span&gt;blog-deploy-user
aws&lt;span class="w"&gt; &lt;/span&gt;iam&lt;span class="w"&gt; &lt;/span&gt;put-user-policy&lt;span class="w"&gt; &lt;/span&gt;--user-name&lt;span class="w"&gt; &lt;/span&gt;blog-deploy-user&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--policy-name&lt;span class="w"&gt; &lt;/span&gt;s3-bucket-deploy&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--policy-document&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'{&lt;/span&gt;
&lt;span class="s1"&gt;  "Version": "2012-10-17",&lt;/span&gt;
&lt;span class="s1"&gt;  "Statement": [&lt;/span&gt;
&lt;span class="s1"&gt;    {&lt;/span&gt;
&lt;span class="s1"&gt;      "Effect": "Allow",&lt;/span&gt;
&lt;span class="s1"&gt;      "Action": [&lt;/span&gt;
&lt;span class="s1"&gt;        "s3:PutObject",&lt;/span&gt;
&lt;span class="s1"&gt;        "s3:DeleteObject",&lt;/span&gt;
&lt;span class="s1"&gt;        "s3:GetObject",&lt;/span&gt;
&lt;span class="s1"&gt;        "s3:ListBucket"&lt;/span&gt;
&lt;span class="s1"&gt;      ],&lt;/span&gt;
&lt;span class="s1"&gt;      "Resource": [&lt;/span&gt;
&lt;span class="s1"&gt;        "arn:aws:s3:::your-bucket",&lt;/span&gt;
&lt;span class="s1"&gt;        "arn:aws:s3:::your-bucket/*"&lt;/span&gt;
&lt;span class="s1"&gt;      ]&lt;/span&gt;
&lt;span class="s1"&gt;    }&lt;/span&gt;
&lt;span class="s1"&gt;  ]&lt;/span&gt;
&lt;span class="s1"&gt;}'&lt;/span&gt;
aws&lt;span class="w"&gt; &lt;/span&gt;iam&lt;span class="w"&gt; &lt;/span&gt;create-access-key&lt;span class="w"&gt; &lt;/span&gt;--user-name&lt;span class="w"&gt; &lt;/span&gt;blog-deploy-user
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Save those credentials. You'll need them in a moment.&lt;/p&gt;
&lt;h3&gt;Add secrets to GitHub&lt;/h3&gt;
&lt;p&gt;In your repository settings, go to &lt;strong&gt;Secrets and variables&lt;/strong&gt; → &lt;strong&gt;Actions&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Add the following secrets:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;The workflow file&lt;/h3&gt;
&lt;p&gt;Create &lt;code&gt;.github/workflows/deploy.yml&lt;/code&gt;:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;Build and Deploy Blog&lt;/span&gt;

&lt;span class="nt"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;push&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;branches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;main&lt;/span&gt;

&lt;span class="nt"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;build_and_deploy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;runs-on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;ubuntu-latest&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;actions/checkout@v4&lt;/span&gt;

&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;Set up Python&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;actions/setup-python@v5&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;python-version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;'3.11'&lt;/span&gt;

&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;Install Nikola&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;pip install 'nikola[extras]' watchdog aiohttp&lt;/span&gt;

&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;Build blog&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;nikola build&lt;/span&gt;

&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;Configure AWS credentials&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;aws-access-key-id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${{ secrets.AWS_ACCESS_KEY_ID }}&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;aws-secret-access-key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${{ secrets.AWS_SECRET_ACCESS_KEY }}&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="nt"&gt;aws-region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;us-west-2&lt;/span&gt;

&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;Deploy to S3&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p p-Indicator"&gt;|&lt;/span&gt;
&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="no"&gt;aws s3 sync output/ s3://your-bucket/ \&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="no"&gt;--delete \&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="no"&gt;--exclude "*.md" \&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="no"&gt;--exclude ".git/*"&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;--delete&lt;/code&gt; flag removes files from S3 that aren't in your build output (useful when renaming posts). The &lt;code&gt;--exclude&lt;/code&gt; flags skip markdown source and git files.&lt;/p&gt;
&lt;h3&gt;A note on the official Nikola action&lt;/h3&gt;
&lt;p&gt;The &lt;a href="https://github.com/getnikola/nikola-action"&gt;&lt;code&gt;getnikola/nikola-action&lt;/code&gt;&lt;/a&gt; is designed for &lt;a href="https://docs.github.com/en/pages/getting-started-with-github-pages"&gt;GitHub Pages deployments&lt;/a&gt; (&lt;code&gt;github_deploy&lt;/code&gt;), not S3. If you're using S3 (or another deployment method), a custom workflow like this is simpler and more transparent.&lt;/p&gt;
&lt;p&gt;The action also had a subtle issue: running inside a Docker container, it couldn't find the repository due to git's &lt;a href="https://github.com/actions/runner/issues/2033"&gt;&lt;code&gt;safe.directory&lt;/code&gt;&lt;/a&gt; check (a &lt;a href="https://github.blog/2022-04-12-git-security-vulnerability-announced/"&gt;security feature added in Git 2.36&lt;/a&gt;). The workaround was to create &lt;code&gt;.gitconfig&lt;/code&gt; in the runner's home directory before the container ran, but at that point, using a direct Python install was cleaner. (&lt;a href="https://github.com/actions/runner/issues/2033"&gt;See the related runner issue for details.&lt;/a&gt;)&lt;/p&gt;
&lt;h3&gt;Next steps&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Customize the &lt;a href="https://docs.aws.amazon.com/cli/latest/reference/s3/sync.html"&gt;S3 sync&lt;/a&gt; command for your bucket and path&lt;/li&gt;
&lt;li&gt;Add &lt;a href="https://docs.aws.amazon.com/cloudfront/latest/developerguide/Invalidation.html"&gt;CloudFront cache invalidation&lt;/a&gt; if you use &lt;a href="https://aws.amazon.com/cloudfront/"&gt;CloudFront&lt;/a&gt; as a CDN&lt;/li&gt;
&lt;li&gt;Add &lt;a href="https://slack.com/"&gt;Slack&lt;/a&gt; or Discord notifications on deployment success or failure using &lt;a href="https://docs.github.com/en/actions"&gt;GitHub Actions workflows&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Consider &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control"&gt;&lt;code&gt;--cache-control&lt;/code&gt;&lt;/a&gt; headers to bust caches on HTML files (e.g., &lt;code&gt;aws s3 cp ... --cache-control "max-age=300"&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Deploy from the comfort of your editor. No manual builds, no SSH tunnels.&lt;/p&gt;</description><category>automation</category><category>aws-s3</category><category>ci-cd</category><category>github-actions</category><category>nikola</category><guid>https://blog.tedder.dev/posts/automating-nikola-deployments-github-actions/</guid><pubDate>Sun, 12 Jul 2026 21:40:00 GMT</pubDate></item></channel></rss>