Deploying SvelteKit Apps with Ansible
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?