Developer Tools AWS

Pluto.jl 1.1.0 on AWS User Guide

| Product: Pluto.jl 1.1.0 on Ubuntu 24.04 on AWS

Overview

This guide covers launching and using the Pluto.jl 1.1.0 on Ubuntu 24.04 AMI from cloudimg on Amazon EC2. It is one version of the cloudimg developer and API tools listing on AWS Marketplace; you pick it as the software version when you subscribe and launch.

Pluto.jl is a notebook for the Julia language that is reactive: when you change a cell, every cell that depends on it runs again on its own, so what you see always matches the code. Notebooks are plain .jl files, each one carries its own package list, and you work in them from any browser while the computation runs on your EC2 instance.

The cloudimg image runs Pluto as a private notebook server:

  • HTTPS from the first boot. nginx serves Pluto on port 443 with a certificate generated on your instance's first boot. Port 80 only redirects to HTTPS, and a request for any name other than your instance's own gets no answer.
  • No default password. The image contains no password, no certificate and no host name. First boot generates them and writes them to /var/lib/cloudimg/pluto-credentials.txt (root only). nginx keeps only a SHA-512 crypt hash of the password, and it asks for the password on every page, file and notebook connection.
  • Pluto's own secret stays on. Pluto opens and creates notebooks only for a browser that holds its secret cookie (Secure, HttpOnly, SameSite=Strict), which it gives to the page you signed in to. Another website cannot use your signed-in browser to open a notebook: cross-site requests and cross-site notebook connections are refused.
  • Current Julia and Pluto. Julia 1.13.3 (the official release, checked against its published SHA-256 digest) and Pluto 1.1.0, with every one of Pluto's 70 dependencies pinned by its Manifest and precompiled into the image, so the editor and each new notebook start without compiling Pluto first.
  • Notebooks that cannot reach the metadata service. Pluto and every notebook run as their own account, pluto, with no login shell and no sudo. A notebook cell can reach the internet (to install packages or fetch data) but not the EC2 instance metadata service, so code in a notebook cannot read the instance's IAM role credentials.
  • Requests limited per address, by the address nginx accepted the connection from (a forged X-Forwarded-For header changes nothing).
  • Fully patched Ubuntu 24.04 LTS at build time, with unattended security upgrades switched on.

What is on the image: Julia 1.13.3 (the official julia-1.13.3-linux-x86_64.tar.gz), Pluto 1.1.0 and its dependencies in a read-only package depot, nginx, Ubuntu 24.04 LTS, cloud-init and the AWS Systems Manager agent. The build record, Julia's licence and the licence of every package Pluto uses are in /usr/share/doc/cloudimg-pluto/.

Default user: ubuntu · Architecture: x86_64 · Recommended instance type: m5.large

Prerequisites

  • An AWS account subscribed to the listing, an EC2 key pair and a VPC subnet.
  • A security group that allows TCP 443 (notebooks), TCP 80 (redirects to HTTPS) and TCP 22 (SSH). A notebook runs any Julia code you type, so open 443 and 22 only to your own networks (for example <your-mgmt-cidr>), never to the whole internet if you can avoid it.
  • A modern browser (Chrome, Edge, Firefox or Safari).

Step 1: Launch the instance

In the AWS Marketplace listing choose Continue to Subscribe, then Continue to Configuration, pick Pluto.jl 1.1.0 on Ubuntu 24.04 as the software version and your Region, then Launch through EC2. Choose m5.large (8 GiB: Julia compiles the packages your notebooks use the first time they load, and that needs memory; a t3.medium is enough for light notebooks), your key pair, your subnet and the security group above.

Your notebooks and the packages they install live on the root volume, in /var/lib/pluto. The image's root volume is 16 GiB; give it more at launch if your notebooks will use large packages or data sets (for example 50 GiB).

With the AWS CLI (replace the values in angle brackets):

aws ec2 run-instances --region <region> --image-id <ami-id-from-the-listing> \
  --instance-type m5.large --key-name <your-key-pair> \
  --security-group-ids <your-security-group> --subnet-id <your-subnet> \
  --metadata-options HttpTokens=required,HttpEndpoint=enabled \
  --block-device-mappings '[{"DeviceName":"/dev/sda1","Ebs":{"VolumeSize":50,"VolumeType":"gp3"}}]' \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=pluto}]'

An Elastic IP keeps the instance's address, and so its name and certificate, the same across stops and starts.

Step 2: Connect to your instance

Version SSH user
Pluto.jl 1.1.0 on Ubuntu 24.04 ubuntu
ssh -i <your-key.pem> ubuntu@<public-ip>

The login message shows your notebook server's address and where its password is.

Step 3: Check the services

First boot takes under a minute: it sets the server's name, generates the certificate and the password, and only then starts Pluto and nginx. Pluto itself takes a few more seconds to load. Check that everything is running:

systemctl is-active pluto-egress-guard pluto nginx \
  && sudo test -f /var/lib/cloudimg/pluto-firstboot.done && echo FIRSTBOOT_DONE
active
active
active
FIRSTBOOT_DONE

Only SSH and nginx listen on the network. Pluto (the julia process) listens on the loopback address 127.0.0.1:1234 behind nginx; while a notebook is open, its own Julia process also listens on a loopback port:

sudo ss -lntp | awk 'NR>1 && $4 !~ /^127\.0\.0\.5[34]/ {print $4, $6}' | sed -E 's/,pid=[0-9]+,fd=[0-9]+//g' | sort -u | tee /dev/stderr \
  | awk '$1 !~ /^127\./ {sub(/.*:/, "", $1); print $1}' | sort -un | xargs | grep -qx '22 80 443' && echo "network listeners: 22 80 443"
0.0.0.0:22 users:(("sshd"),("systemd"))
0.0.0.0:443 users:(("nginx"),("nginx"),("nginx"))
0.0.0.0:80 users:(("nginx"),("nginx"),("nginx"))
127.0.0.1:1234 users:(("julia"))
[::]:22 users:(("sshd"),("systemd"))
[::]:443 users:(("nginx"),("nginx"),("nginx"))
[::]:80 users:(("nginx"),("nginx"),("nginx"))
network listeners: 22 80 443

Step 4: Get your address and password

Run sudo cat /var/lib/cloudimg/pluto-credentials.txt to see your notebook server's address, user name and password. The file is readable by root only and holds these settings, all generated for this instance:

sudo sed -n 's/^\(PLUTO_[A-Z0-9_]*\)=.*/\1/p' /var/lib/cloudimg/pluto-credentials.txt | tee /dev/stderr \
  | grep -qx PLUTO_PASSWORD && echo "credentials present"
PLUTO_URL
PLUTO_HOST
PLUTO_USERNAME
PLUTO_PASSWORD
PLUTO_TLS_CERT
PLUTO_TLS_SHA256_FINGERPRINT
credentials present

PLUTO_URL is https://<your instance's public DNS name>/, PLUTO_USERNAME is admin and PLUTO_PASSWORD is the password. The settings that are not secret can be shown on their own:

sudo grep -E '^PLUTO_(URL|USERNAME|TLS_SHA256_FINGERPRINT)=' /var/lib/cloudimg/pluto-credentials.txt \
  | tee /dev/stderr | grep -q '^PLUTO_URL=https://'
PLUTO_URL=https://ec2-203-0-113-10.compute-1.amazonaws.com/
PLUTO_USERNAME=admin
PLUTO_TLS_SHA256_FINGERPRINT=6C:1A:...:9E

nginx stores the password only as a SHA-512 crypt hash, in a file readable only by root and nginx:

sudo grep -c '^admin:\$6\$' /etc/nginx/pluto.htpasswd | grep -qx 1 \
  && sudo stat -c '%U:%G %a' /etc/nginx/pluto.htpasswd | tee /dev/stderr | grep -qx 'root:www-data 640' && echo "hash only"
root:www-data 640
hash only

Step 5: Sign in from your browser

Open PLUTO_URL in your browser. The certificate is self-signed and was generated on your instance, so the browser warns about it the first time. Before you accept it, check that it is your instance's certificate: the browser's certificate viewer shows a SHA-256 fingerprint that must match PLUTO_TLS_SHA256_FINGERPRINT. Or copy the certificate to your machine and compare it there:

scp -i <your-key.pem> ubuntu@<public-ip>:/etc/ssl/pluto/pluto.crt ./pluto.crt
openssl x509 -in pluto.crt -noout -fingerprint -sha256

To stop the warning altogether, add pluto.crt to your machine's trusted certificates (Keychain Access on macOS, certmgr on Windows), or use your own domain and a public certificate (Step 9).

The browser then asks for a user name and password: enter admin and PLUTO_PASSWORD. Pluto's main menu opens, where you create a notebook, open one from the notebook folder or try the featured examples:

The Pluto.jl main menu, served over HTTPS by the instance

The browser remembers the password until you close it. To sign out, close every window of the browser (or use a private window for the session).

Step 6: Work in a notebook

The image puts a short welcome notebook in the notebook folder, /var/lib/pluto/notebooks. In the main menu, type /var/lib/pluto/notebooks/welcome.jl under Open a notebook and choose Open. Pluto opens a notebook file it did not create itself in Safe preview, which shows the code without running it, so a notebook from someone else cannot run anything until you have read it; choose Run notebook code at the top to run it. Change x and watch every value that depends on it update:

The welcome notebook: changing x re-runs y, squares and the summary on their own

Create a new notebook starts an empty one. Pluto saves notebooks to /var/lib/pluto/notebooks (choose Save notebook... at the top to name it). A notebook's packages are part of the notebook: write using Plots (or using DataFrames, using PlutoUI and so on) in a cell and Pluto installs the package for that notebook from the Julia package server. The first time a package is used, Julia downloads and compiles it, which can take a few minutes; after that it loads quickly. Installed packages and their compiled code are kept in /var/lib/pluto/.julia.

To bring a notebook in from elsewhere, use Open a notebook with a URL to a .jl file, or copy the file into the notebook folder over SSH (sudo install -o pluto -g pluto -m 0640 my-notebook.jl /var/lib/pluto/notebooks/).

Step 7: What a notebook can reach

Every notebook runs as the pluto account, inside the Pluto service. It can reach the internet (the Julia package server, data downloads, APIs) but not the EC2 instance metadata service. The screenshot below is that check, run in a notebook cell: the metadata service is refused and a public site answers:

A notebook cell: the instance metadata service is refused, the Julia package server answers

You can run the same check over SSH. The metadata service is refused for the pluto account and still answers for ubuntu (your SSH account), and the Julia package server answers for pluto:

{ sudo -u pluto curl -s -m 5 -o /dev/null -w 'pluto -> metadata: %{http_code}\n' -X PUT http://169.254.169.254/latest/api/token -H 'X-aws-ec2-metadata-token-ttl-seconds: 60'
  sudo -u pluto curl -sL -m 20 -o /dev/null -w 'pluto -> pkg.julialang.org: %{http_code}\n' https://pkg.julialang.org/registries
  curl -s -m 5 -o /dev/null -w 'ubuntu -> metadata: %{http_code}\n' -X PUT http://169.254.169.254/latest/api/token -H 'X-aws-ec2-metadata-token-ttl-seconds: 60'
} | tee /dev/stderr | xargs | grep -qx 'pluto -> metadata: 000 pluto -> pkg.julialang.org: 200 ubuntu -> metadata: 200' && echo "metadata blocked for notebooks"
pluto -> metadata: 000
pluto -> pkg.julialang.org: 200
ubuntu -> metadata: 200
metadata blocked for notebooks

Two things enforce this: the Pluto service's own network filter (systemd IPAddressDeny) and an nftables rule that rejects the metadata addresses for the pluto account anywhere on the instance. If a notebook needs AWS credentials, give it its own, with only the permissions it needs (for example in a file under /var/lib/pluto that only pluto can read).

A notebook can reach your VPC and other private addresses, like any program on the instance; Pluto itself makes no requests on anyone else's behalf, so the image does not fence private networks.

The pluto account has no sudo and cannot write outside /var/lib/pluto. To install system libraries, use your SSH session as ubuntu (sudo apt-get install ...).

Step 8: Run the self-test

The image ships a self-test that checks the services and listening ports, that a request with no password or a wrong one is refused on every path and on the notebook connection, that the password in the credentials file opens Pluto over HTTPS with this instance's certificate and sets Pluto's secret cookie (Secure, HttpOnly, SameSite=Strict), that a new notebook and the notebook connection need that cookie (and that nginx refuses a //new path), that a notebook connection from another site is refused, and that an uploaded notebook computes:

sudo pluto-selftest | tail -n 1 | tee /dev/stderr | grep -q '^PLUTO_SELFTEST_OK '
PLUTO_SELFTEST_OK no password or a wrong one = 401 everywhere; the note's password opens https://ec2-203-0-113-10.compute-1.amazonaws.com/ (Secure+HttpOnly+SameSite=Strict secret cookie); without the cookie /new and the WebSocket = 403, //new = 400; same-origin WebSocket 101, cross-origin 403; an uploaded notebook computed CLOUDIMG-SELFTEST-42

The self-test uploads, runs and deletes its own small notebook; it does not touch yours.

Step 9: Change the password or use your own domain

To give the server a new random password (the old one stops working at once), run the command below, then read the new PLUTO_PASSWORD with sudo cat /var/lib/cloudimg/pluto-credentials.txt. Open browser tabs must sign in again; running notebooks keep running:

sudo pluto-rotate-password

The new password goes to the hasher on standard input; only its SHA-512 crypt hash goes into /etc/nginx/pluto.htpasswd, and the password never appears on a command line or in a log.

To use your own domain, point a DNS name at the instance (an Elastic IP keeps it stable), then make it the server's name. This generates a new certificate for that name and pins nginx to it:

sudo pluto-set-site notebooks.example.com

To use a certificate from a public CA instead, for example one issued by certbot with a DNS challenge, run pluto-set-site first, then replace /etc/ssl/pluto/pluto.crt (full chain) and /etc/ssl/pluto/pluto.key (mode 0600) and run sudo systemctl reload nginx. Browsers then show no warning.

Step 10: What the front door refuses

nginx answers only for the server's own names, asks for the password on every path, and passes requests to Pluto on loopback. Below: no password (401), the password (200, read from the credentials file without putting it on a command line), a name that is not the server's (no answer, 000), plain HTTP (301 to HTTPS) and a cross-site form post (403, refused before the password is even checked):

H="$(sudo sed -n 's/^PLUTO_HOST=//p' /var/lib/cloudimg/pluto-credentials.txt)"
C=(--cacert /etc/ssl/pluto/pluto.crt --resolve "$H:443:127.0.0.1")
{ curl -s -o /dev/null -w '%{http_code}\n' "${C[@]}" "https://$H/"
  curl -s -o /dev/null -w '%{http_code}\n' "${C[@]}" -K <(sudo sed -n 's/^PLUTO_PASSWORD=\(.*\)$/user = "admin:\1"/p' /var/lib/cloudimg/pluto-credentials.txt) "https://$H/"
  curl -s -o /dev/null -w '%{http_code}\n' -k -H 'Host: someone-else.example' https://127.0.0.1/
  curl -s -o /dev/null -w '%{http_code}\n' --resolve "$H:80:127.0.0.1" "http://$H/"
  curl -s -o /dev/null -w '%{http_code}\n' "${C[@]}" -X POST -H 'Origin: https://evil.example' "https://$H/new"
} | xargs | tee /dev/stderr | grep -qx '401 200 000 301 403' && echo "front door OK"
401 200 000 301 403
front door OK
  • Pluto's secret. On top of the password, Pluto opens a notebook from a path or URL, creates a notebook and accepts a notebook connection only from a browser holding its secret cookie, which it sets on the page you open after signing in. The cookie is SameSite=Strict, so a link or form on another website cannot carry it.
  • Unusual paths. A request whose path contains // gets 400 from nginx before the password is checked. Pluto reads a path such as //new differently in its secret check and in its router, so nginx never lets one through.
  • Cross-site requests. A request that changes something (anything but GET, HEAD or OPTIONS) and every notebook connection must come from Pluto's own page: with another site's Origin header nginx answers 403 before Pluto sees the request.
  • Request limits. Each client address may make 20 requests a second (bursts of 200, since opening Pluto loads many files at once) and hold 128 connections; over the limit nginx answers 429. nginx replaces any X-Forwarded-For header with the address it accepted the connection from and blanks X-Real-IP, Forwarded and similar headers, so a client cannot change the address the limits use. If you put a load balancer in front of the instance, every client then shares the load balancer's address for the limits.
  • Sizes and methods. Notebook uploads are limited to 64 MiB; TRACE and CONNECT get 405.
  • Headers. Responses carry Strict-Transport-Security, X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, a Content-Security-Policy of frame-ancestors 'self', Referrer-Policy: same-origin and a Permissions-Policy that turns off the payment and USB APIs (the camera and microphone stay available to notebook widgets you write), and nginx does not advertise its version.

What is logged: nginx's access log (/var/log/nginx/access.log) records the client address, request path and status, without query strings. nginx's error log records only start-up problems, not refused requests, so a password in a rejected request never reaches the disk. Pluto logs to the journal (journalctl -u pluto), and the image's log filter withholds any message that would contain Pluto's secret.

What Pluto fetches from the internet: the editor page loads some optional resources from public servers in your browser (the featured notebook list from featured.plutojl.org, maths fonts from the jsDelivr CDN) and sends an anonymous usage ping to stats.plutojl.org. The notebooks, your code and your data stay on the instance.

Step 11: Back up and restore

Everything Pluto keeps (your notebooks, and the packages and compiled code they installed) is in /var/lib/pluto. Take an EBS snapshot of the root volume, or copy the notebooks off the instance (the packages are re-installed from each notebook's own package list when you open it):

sudo tar -C /var/lib/pluto -czf pluto-notebooks-$(date +%F).tar.gz notebooks

To restore into a new instance of this image, unpack the archive as pluto: sudo -u pluto tar -C /var/lib/pluto -xzf - < pluto-notebooks-<date>.tar.gz. Keep your notebooks in git as well: they are plain Julia files.

Step 12: Security notes and the build record

The certificate generated at first boot is a server certificate only: it cannot sign other certificates.

openssl x509 -in /etc/ssl/pluto/pluto.crt -noout -ext basicConstraints,keyUsage,extendedKeyUsage | tee /dev/stderr | grep -q 'CA:FALSE'
X509v3 Basic Constraints: critical
    CA:FALSE
X509v3 Key Usage: critical
    Digital Signature, Key Encipherment
X509v3 Extended Key Usage: 
    TLS Web Server Authentication

Julia is the 1.13.3 release, Pluto is 1.1.0 as pinned by the Manifest, and the notebook account has no login shell and no sudo:

/opt/julia/julia-1.13.3/bin/julia --version | tee /dev/stderr | grep -qx 'julia version 1.13.3' \
  && awk '/^\[\[deps\.Pluto\]\]$/ {f=1; next} /^\[\[/ {f=0} f && /^version/' /opt/pluto/env/Manifest.toml | tee /dev/stderr | grep -qx 'version = "1.1.0"' \
  && sed -n '1,4p' /usr/share/doc/cloudimg-pluto/BUILD-INFO.txt >&2 \
  && getent passwd pluto | cut -d: -f1,6,7 | tee /dev/stderr | grep -qx 'pluto:/var/lib/pluto:/usr/sbin/nologin' \
  && sudo -l -U pluto 2>&1 | grep -q 'not allowed to run sudo' && echo "Julia 1.13.3, Pluto 1.1.0; pluto: no login shell, no sudo"
julia version 1.13.3
version = "1.1.0"
pluto             Pluto.jl 1.1.0 (github.com/JuliaPluto/Pluto.jl, 2026-10-27), MIT; /opt/pluto/env/Manifest.toml pins it and
                  its 70 dependencies (33 registered packages: MIT, HypertextLiteral ISC; licences in licences/)
julia             Julia 1.13.3 (official julialang-s3 linux-x86_64 tarball, MIT), /opt/julia/julia-1.13.3
                  julia-1.13.3-linux-x86_64.tar.gz sha256 5c61cb4b40332cb5a3504e7779e2f5dac093caa4a1116ded19f01413f85c63e4 (= upstream's julia-1.13.3.sha256)
pluto:/var/lib/pluto:/usr/sbin/nologin
Julia 1.13.3, Pluto 1.1.0; pluto: no login shell, no sudo

Pluto runs as pluto with no capabilities and NoNewPrivileges, sees the rest of the system read-only (ProtectSystem=strict, writable only under /var/lib/pluto), has a private /tmp and cannot see other users' processes. It cannot read the credentials file, the password hash or the TLS key, and nginx never passes the password on to it. Pluto's package depot in /opt/julia/depot is owned by root and read-only to notebooks; packages a notebook installs go to /var/lib/pluto/.julia. Some sandbox settings are deliberately relaxed because Julia compiles code as it runs: JIT compilation (no MemoryDenyWriteExecute), any system call (no SystemCallFilter), a full /proc for its own processes, and netlink sockets.

Do not run Pluto yourself as ubuntu or root (for example julia -e 'using Pluto; Pluto.run()'): that starts a second server without the password, the secret or the metadata block. Manage the service with systemctl and the pluto-* helpers instead.

Julia and Pluto are MIT licensed; Pluto's dependencies are MIT (HypertextLiteral is ISC). Every licence is in /usr/share/doc/cloudimg-pluto/. nginx comes from Ubuntu's main archive.

Step 13: Keep it up to date

Ubuntu security updates, including nginx, install automatically. To apply everything now:

sudo apt-get update && sudo apt-get upgrade -y

Packages your notebooks use update when you change a notebook's package versions in Pluto (each notebook's package list is stored in the notebook). New Julia and Pluto releases arrive as new versions of the AWS Marketplace listing: launch the new version, copy /var/lib/pluto/notebooks into it (Step 11) and run sudo pluto-set-site with your domain if you use one. Julia is installed from its release tarball, not from an apt repository, so apt-get upgrade does not change it.

Support

  • Email: support@cloudimg.co.uk
  • Website: https://www.cloudimg.co.uk

The other versions on this listing are on the developer and API tools guide.