web dev & more!

Deploying SvelteKit Apps with Ansible

Published: December 16, 2024
8 - 10 minutes

This best programmers are lazy and let me tell you, I am incredibly lazy. That means I’m the best!

Massive egos aside, I’m only lazy in that I prefer to automate simple, repetitive tasks. When I find myself regularly logging in to a server to do some maintenance or simple task, I’ll usually spend an upfront cost in time to automate that task. And when it comes to homelabbing, saving time and preventing headaches is key to keeping the passion alive.

Why Ansible?

Homelabbing is a labor of love. But surely, there is more to life than backing up and updating servers. This is where Ansible comes in. I’ve been using Ansible to automate various tasks in my homelab for some time now with great success. In fact, my current most popular repository on GitHub is the set of scripts I use to manage my own homelab! Apparently, I’m not the only one who values my time.

When this site was built with WordPress, I used git to deploy new code via a custom theme or plugin. Having completely rebuilt this site, I needed a consistent, cheap, and easy method to launch it. It only makes sense to use the tools I’ve already got, right? Besides, I’ve deployed Node.js apps with Ansible before so it should be a snap.

Requirements

I’ve already got a means of provisioning a new Ubuntu server on my host quickly. But to configure that environment with the correct software, I would need a new Ansible playbook.

To put together a playbook for this site, I started by outlining requirements. Firstly, I would need to ensure my domain pointed to the correct IP address. Secondly, I would need a web server to handle the incoming HTTP requests. I would also need a Node.js back-end to power things like the contact form. Of course, there would need to be process for compiling the Svelte files of the site and a means of copying the built product to the server. Finally, I would need SSL certificates.

Dynamic DNS

A tricky thing about self-hosting anything on a residential internet connection is that the Internet Service Provider (ISP) can change your home IP address at anytime. If the router loses power or reboots, it’s very likely the ISP will hand your current IP address to another user. And then your website domain is point to some random person’s house on the other end of town. If you’re lucky, they don’t also self-host anything and your website simply doesn’t work. If you’re unlucky, your domain is now hosting malware. To keep my domain pointed at the correct IP address, I hacked together a bash script that runs every 20 minutes and updates the IP address my domain is pointing to.

This is actually something I have written about previously. If you’re interested in the script, see the post here!.

Nginx

The server needs a means of handling incoming HTTP requests. I’ve already got an Nginx role in my tooling so I didn’t waste time bothering with something else. Nginx is great as a reverse proxy and that’s basically all it’s doing here; handling incoming HTTP requests, and sending them off to Node.js.

Node.js + systemd services

I originally considered using Deno for the back-end but decided against it for one simple reason; time. I didn’t want to spend more time learning a new environment. I already had a Node.js role and wanted to get this site up and running ASAP.

But I ran into an issue with my original role. At the time, the repository it installed from only went up to v20.5. To get environment variables loaded into the Node.js server, I would need at least v20.6! I spent some time trying to get an install with NVM working but after more research, I learned later on that this could be handled by the system service, which I would be using anyways!

system services

Once Nginx hands off the HTTP requests to the Node.js app, we’re all set, right?

Wrong.

If, for whatever reason, my hastily written yet perfectly infallible code were to fail, Node.js would crash, taking my site offline. And that’s bad. Since I may not know that it has failed and my site is offline, I need it to start back up on its own. This is where Linux services come in.

I can create a service file which will run in the background of the container and relies on systemd, which is already installed. That service will be responsible for starting Node.js on startup, restarting it on failures, and logging everything. Conveniently, it can also be used to load environment variables!

It took a little troubleshooting which involved running the commands systemctl daemon-reload followed by systemctl start nodejs and systemctl status nodejs repeatedly, but I did get it working eventually! See below:

[Unit]
Description=nodejs server

[Service]
ExecStart=/usr/bin/node /var/www/html/index.js
Restart=on-failure
# Output to syslog
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=nodejs
Environment=NODE_ENV=production PORT=5173
EnvironmentFile=/var/www/.env

[Install]
WantedBy=multi-user.target

SvelteKit

While SvelteKit sites don’t need to ship any extra blobs to load the application, they do need to compile all of that sweet, sugary syntax into proper JavaScript. Fortunately, this is as simple as running the command npm run build. Once that build step has been run, the entire build directory can be copied to the web server.

SSL

Jeff Geerling has an Ansible role for creating and managing SSL certificates with Ansible. But the official Let’s Encrypt documentation suggest installing it with Pip and honestly, it doesn’t take long to run these few commands. Since I’ve also got a subdomain, I figured I would manually log in to the server and set this up since it really only takes a couple of minutes anyways.

While this required a bit of elbow grease, it was a one-time thing so I’m not losing any sleep over it. Let’s Encrypt Certbot is practically set it and forget (don’t actually forget about it though, that’d be bad).

Speeding Things Up

The Ansible copy module is slow. Like, painfully slow. It works just fine for a few files here or there but when attempting to copy over the entire compiled app, the playbook would take around 17 minutes and 23 seconds to complete. The bulk of that time was spent copying over the build directory using the copy module. Seventeen minutes is too long for me. I need to be able to quickly push out changes and I can’t be sitting around, waiting for deployments to finish all day.

I was laying awake in bed when I realized I could just use rsync, and wouldn’t you know it, others had the same thought. I settled on the ansible.posix.synchronize module and my deployments dropped down to 1 minute and 17 seconds. That’s a whopping 92% decrease in time!

The Playbook

Either you’ve read all of this, followed along completely, and are amazing or you’re just as lazy as me and skipped to the bottom for the answers (us lazy folks don’t have time to read).

Well the code is up next but I would highly encourage you to check out my Ansible Proxmox Automation repository for the most recent code and all your SvelteKit Automation needs. Who knows if I’ll come back and edit this post as the code changes 🤷? In any case, here’s the playbook, followed by all of the necessary roles:

deploy-closingtags.yml

---
- hosts: nodejs
  tasks:
    - name: Add system user
      user:
        name: USER_NAME_HERE
        groups: sudo
        append: yes
        shell: /bin/bash
        create_home: yes

  roles:
    - ddns
    - nginx
    - nodejs
    - closingtags

Roles

The playbook isn’t super helpful without the roles so check those out here.

roles/ddns/tasks/main.yml

- name: Add ddns bash script
  tags: ddns, bash
  copy:
    src: ddns.sh # https://gist.github.com/Dilden/cbe4787b5e776eb13989df1ba8b54612 (edit & place in roles/ddns/files/)
    dest: /root/ddns.sh
    owner: root
    group: root
    mode: 0744 # must be executable!

- name: Setup cronjob to update DNS
  cron:
    name: ddns cron
    user: www-data
    job: '/root/ddns.sh'
    minute: '*/20'
    state: present

roles/nginx/tasks/main.yml

- name: Install nginx
  tags: install, nginx
  apt:
    name: nginx
    state: latest

- name: Start nginx
  tags: install, nginx
  service:
    name: nginx
    state: started

roles/nodejs/tasks/main.yml

---
- name: Install GPG
  tags: nodejs, install, setup
  apt:
    name: gnupg
    update_cache: yes
    state: present

- name: Install the gpg key for nodejs LTS
  apt_key:
    url: 'https://deb.nodesource.com/gpgkey/nodesource.gpg.key'
    state: present

- name: Install the nodejs LTS repos
  apt_repository:
    repo: 'deb https://deb.nodesource.com/node_{{ NODEJS_VERSION }}.x {{ ansible_distribution_release }} main'
    state: present
    update_cache: yes

- name: Install NodeJS
  tags: nodesjs, install
  apt:
    name: nodejs
    state: latest

roles/nodejs/defaults/main.yml

---
NODEJS_VERSION: '20'
ansible_distribution_release: 'focal'

roles/closingtags/tasks/main.yml

- name: Build closingtags site locally
  tags: closingtags, build, deploy
  shell: npm run build
  args:
    chdir: ~/Dev/projects/closingtags/
  delegate_to: 127.0.0.1

- name: Create temp dir
  tags: closingtags, build, deploy
  file:
    path: /var/www/temp
    state: directory
    owner: www-data
    group: www-data
    mode: 0644

- name: Sync build dir to server
  tags: closingtags, build, deploy
  ansible.posix.synchronize:
    src: ~/Dev/projects/closingtags/build/
    dest: /var/www/temp/
    recursive: true

- name: Copy package-lock.json to server
  tags: closingtags, build, deploy
  copy:
    src: ~/Dev/projects/closingtags/package-lock.json
    dest: /var/www/temp/package-lock.json

- name: Copy package.json to server
  tags: closingtags, build, deploy
  copy:
    src: ~/Dev/projects/closingtags/package.json
    dest: /var/www/temp/package.json

- name: Rename old html to backup
  tags: closingtags, build, deploy
  copy:
    remote_src: true
    src: /var/www/html
    dest: /var/www/site-{{ now(utc=true, fmt='%Y-%m-%d_%H-%M')}}

- name: Delete /var/www/html
  tags: closingtags, build, deploy
  file:
    path: /var/www/html
    state: absent

- name: Rename temp dir to web root dir (html)
  tags: closingtags, build, deploy
  copy:
    remote_src: true
    src: /var/www/temp/
    dest: /var/www/html/
    owner: www-data
    group: www-data
    mode: 0644

- name: Delete temp dir
  file:
    path: /var/www/temp
    state: absent

- name: Delete builds older than 30 days
  tags: closingtags, build, deploy
  shell: find /var/www/site-* -mtime +30 -exec rm {} ;

- name: Install dependencies from lockfile
  tags: closingtags, build, deploy
  shell: npm ci --ignore-scripts
  args:
    chdir: /var/www/html/

- name: Dependecy permissions
  file:
    path: /var/www/html
    owner: www-data
    group: www-data
    mode: 0644

- name: Create service file
  tags: closingtags, build, deploy
  template:
    src: files/service
    dest: /etc/systemd/system/nodejs.service
  register: service_conf

- name: Reload systemd daemon
  tags: closingtags, build, deploy, systemd
  systemd:
    daemon_reload: yes
  when: service_conf.changed

- name: Restart NodeJS service
  tags: closingtags, build, deploy
  service:
    name: nodejs
    state: restarted
    enabled: yes

roles/closingtags/files/service

[Unit]
Description=nodejs server

[Service]
ExecStart=/usr/bin/node /var/www/html/index.js
Restart=on-failure
# Output to syslog
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=nodejs
Environment=NODE_ENV=production PORT=5173
EnvironmentFile=/var/www/.env

[Install]
WantedBy=multi-user.target

TODO

After all of that, I can now deploy the site by simply cd'ing into my automation project’s directory and running the command ansible-playbook books/deploy-closingtags.yml --ask-vault-pass. This command effectively launches each new feature of my website!

There are a couple of things I would like to improve in the future. For instance, I’d like to adjust the script so it can checkout the main branch of the repository, ensuring I don’t launch a half-baked feature. Better yet, the script should pull down the main branch from the repository and deploy that. I’d also like to clean up the command that deletes site backups older than 30 days so that it simply leaves only the most recent 10 directories (give or take a few). That way, I can still roll back to an earlier version in case something breaks but I don’t need to retain as many backups. It would also be great to create another script to roll back to a previous version of the site, just in case something did break.

As it stands, this is good enough for now. But what do you think?