Developer Tools AWS

BaGetter 1.6.5 on AWS User Guide

| Product: BaGetter 1.6.5 on Ubuntu 24.04 on AWS

Overview

This guide covers launching and using the BaGetter 1.6.5 on Ubuntu 24.04 AMI from cloudimg on Amazon EC2. It is one version of the cloudimg artifact and container registries listing on AWS Marketplace; you pick it as the software version when you subscribe and launch.

BaGetter is a lightweight, self hosted NuGet server. Your build pipelines push .nupkg packages to it, and dotnet restore, Visual Studio and the nuget client install them from it with the standard NuGet v3 protocol, so internal .NET libraries are shared the same way public ones are from nuget.org.

Upstream BaGetter ships with no push API key, and an empty key means anyone who can reach the server may push and delete packages; it also leaves TLS and serving to you. The cloudimg image makes it a ready to use private feed:

  • TLS from the first boot. nginx serves the feed on port 443 (https://<host>/v3/index.json) with a certificate generated on your instance's first boot, naming the instance's DNS name and IP addresses. The certificate is a server (leaf) certificate, not a certificate authority, so trusting it on a client trusts this one server only. BaGetter itself listens on 127.0.0.1 only, and nothing listens on port 80.
  • A push API key unique to your instance. Pushing, deleting and relisting packages need the X-NuGet-ApiKey key generated on the first boot and written to /root/bagetter-credentials.txt (root only). BaGetter and nginx refuse to start without a real key, so the feed can never come up open for pushes. Reading the feed (restore, search, the web gallery) is open to whoever can reach port 443, as a NuGet feed must be.
  • Your packages on their own disk. The SQLite database and the package store live on a separate 10 GiB gp3 data volume mounted at /var/lib/bagetter, which you can resize on its own.
  • Safe defaults. Mirroring of nuget.org is off, so the instance makes no outbound package fetches; pushing an existing version again is refused instead of overwriting it; deleting a version unlists it.
  • Fail closed and locked down. Requests for host names the instance does not serve are dropped; BaGetter runs as an unprivileged user in a sandboxed service; BaGetter, nginx and the first boot service are blocked from the instance metadata service, and the AMI requires IMDSv2.
  • Built from source on a supported runtime. BaGetter 1.6.5 is compiled from the upstream release (pinned by SHA-256) for the supported .NET 10 runtime from Ubuntu, with its dependencies raised to versions that fix every published advisory in the upstream set, on fully patched Ubuntu 24.04 LTS with unattended security upgrades switched on.

What is on the image: BaGetter 1.6.5 (/opt/bagetter, licence and build provenance in /usr/share/doc/bagetter), the ASP.NET Core 10 runtime, nginx, Ubuntu 24.04 LTS, cloud-init and the AWS Systems Manager agent. No .NET SDK is installed.

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 your build machines can reach.
  • A security group that allows TCP 22 (SSH) from your own networks only, and TCP 443 from the machines that push and restore packages. Nothing else needs to be open.

Step 1: Launch the instance

In the AWS Marketplace listing choose Continue to Subscribe, then Continue to Configuration, pick BaGetter 1.6.5 on Ubuntu 24.04 as the software version and your Region, then Launch through EC2. Choose m5.large, your key pair, and a security group as above. The AMI attaches the 10 GiB data volume for /var/lib/bagetter automatically; increase its size in the launch wizard's storage section if you plan to keep many packages.

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 \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=bagetter}]'

Step 2: Connect to your instance

Version SSH user
BaGetter 1.6.5 on Ubuntu 24.04 ubuntu
ssh -i <your-key.pem> ubuntu@<public-ip>

Step 3: Check the services

First boot generates the certificate and the push API key, then BaGetter and nginx start:

systemctl is-active bagetter nginx
sudo test -f /var/lib/cloudimg/bagetter-firstboot.done && echo FIRSTBOOT_DONE
active
active
FIRSTBOOT_DONE

Only SSH and nginx listen on the network. BaGetter is on loopback behind nginx, and the DNS resolver listens on loopback only:

sudo ss -lntp | awk 'NR>1 {print $4, $6}' | sed -E 's/pid=[0-9]+,fd=[0-9]+/.../g' | sort
0.0.0.0:22 users:(("sshd",...),("systemd",...))
0.0.0.0:443 users:(("nginx",...),("nginx",...),("nginx",...))
127.0.0.1:5000 users:(("dotnet",...))
127.0.0.53%lo:53 users:(("systemd-resolve",...))
127.0.0.54:53 users:(("systemd-resolve",...))
[::]:22 users:(("sshd",...),("systemd",...))
[::]:443 users:(("nginx",...),("nginx",...),("nginx",...))

The package store is on its own volume:

df -h /var/lib/bagetter | awk 'NR==2 {print $2, $6}'
9.8G /var/lib/bagetter

Step 4: Get your feed URL and API key

The credentials file is readable by root only. This prints it with the key hidden; sudo cat /root/bagetter-credentials.txt shows the key itself:

sudo sed -E 's/^(BAGETTER_API_KEY=).*/\1<hidden>/' /root/bagetter-credentials.txt
# BaGetter 1.6.5 - generated on first boot by bagetter-firstboot.service
# This push API key is unique to this instance; none was baked into the image. Keep this file secret.
# Reading the feed (restore, browse) needs no key; pushing, deleting and relisting packages need BAGETTER_API_KEY.

BAGETTER_URL=https://ec2-44-201-173-7.compute-1.amazonaws.com/
BAGETTER_SERVICE_INDEX=https://ec2-44-201-173-7.compute-1.amazonaws.com/v3/index.json
BAGETTER_API_KEY=<hidden>
BAGETTER_CA_CERT=/etc/nginx/tls/bagetter.crt
BAGETTER_CERT_SHA256=D6:35:B4:B5:78:D4:14:36:1C:A8:34:A9:C0:32:E7:3A:7D:29:45:E4:9B:01:44:87:CD:85:E8:6A:B0:C1:85:35
#
# Push:    dotnet nuget push <package>.nupkg --source <BAGETTER_SERVICE_INDEX> --api-key <BAGETTER_API_KEY>
# Restore: dotnet nuget add source <BAGETTER_SERVICE_INDEX> --name bagetter
# The certificate is self-signed: trust /etc/nginx/tls/bagetter.crt on your clients, or use your own (see the guide).
# New push API key:             sudo bagetter-rotate-apikey
# Change the name it serves as: sudo bagetter-set-hostname <your-dns-name>
# User guide: https://www.cloudimg.co.uk/guides/bagetter-1-6-5-on-ubuntu-24-04-aws/

Your instance shows its own DNS name and certificate hash. The service index advertises every NuGet endpoint under that name; to serve under your own name, see Step 8:

curl -s --cacert /etc/nginx/tls/bagetter.crt https://localhost/v3/index.json | jq -r '.resources[] | "\(.["@type"])  \(.["@id"])"' | sort -u | head -6
PackageBaseAddress/3.0.0  https://ec2-44-201-173-7.compute-1.amazonaws.com/v3/package/
PackagePublish/2.0.0  https://ec2-44-201-173-7.compute-1.amazonaws.com/api/v2/package
RegistrationsBaseUrl  https://ec2-44-201-173-7.compute-1.amazonaws.com/v3/registration/
RegistrationsBaseUrl/3.0.0-beta  https://ec2-44-201-173-7.compute-1.amazonaws.com/v3/registration/
RegistrationsBaseUrl/3.0.0-rc  https://ec2-44-201-173-7.compute-1.amazonaws.com/v3/registration/
SearchAutocompleteService  https://ec2-44-201-173-7.compute-1.amazonaws.com/v3/autocomplete

Step 5: Push and find a package

The image carries a tiny test package, /opt/bagetter/testdata/Cloudimg.Smoke.Test.1.0.0.nupkg. This pushes it on the instance itself, first without the key and then with it; the key is handed to curl on standard input, so it never appears in the process list:

sudo bash -c '
  K=$(grep "^BAGETTER_API_KEY=" /root/bagetter-credentials.txt | cut -d= -f2-)
  P=/opt/bagetter/testdata/Cloudimg.Smoke.Test.1.0.0.nupkg
  curl -s --cacert /etc/nginx/tls/bagetter.crt -o /dev/null -w "push without the key -> HTTP %{http_code}\n" -X PUT -F package=@$P https://localhost/api/v2/package
  printf "header = \"X-NuGet-ApiKey: %s\"\n" "$K" | curl -K - -s --cacert /etc/nginx/tls/bagetter.crt -o /dev/null -w "push with the key    -> HTTP %{http_code}\n" -X PUT -F package=@$P https://localhost/api/v2/package'
push without the key -> HTTP 401
push with the key    -> HTTP 201

A second push of the same version answers 409: versions are never overwritten. The package is now in the feed:

curl -s --cacert /etc/nginx/tls/bagetter.crt "https://localhost/v3/search?q=cloudimg" | jq -c '.data[] | {id, version}'
curl -s --cacert /etc/nginx/tls/bagetter.crt https://localhost/v3/package/cloudimg.smoke.test/index.json; echo
{"id":"Cloudimg.Smoke.Test","version":"1.0.0"}
{"versions":["1.0.0"]}

The web gallery at https://<host>/ lists the packages in the feed, with search, and needs no login (it is read only):

The BaGetter package list showing the Cloudimg.Smoke.Test package with its latest version

Each package page gives the install command for the .NET CLI, PackageReference, Paket and the Package Manager console, its versions and a download link:

The BaGetter package page for Cloudimg.Smoke.Test with the dotnet add package command and the version table

The Upload page shows the push command for your feed's service index; add --api-key with the key from Step 4 (see Step 6):

The BaGetter upload page showing the dotnet nuget push command for the instance's service index

To remove the test package from search and restore again, unlist it with the key (DELETE unlists; the file stays so that builds which pinned it keep working):

sudo bash -c '
  K=$(grep "^BAGETTER_API_KEY=" /root/bagetter-credentials.txt | cut -d= -f2-)
  printf "header = \"X-NuGet-ApiKey: %s\"\n" "$K" | curl -K - -s --cacert /etc/nginx/tls/bagetter.crt -o /dev/null -w "unlist -> HTTP %{http_code}\n" -X DELETE https://localhost/api/v2/package/Cloudimg.Smoke.Test/1.0.0'
unlist -> HTTP 204

Step 6: Use the feed from your build machines

Copy the instance's certificate (/etc/nginx/tls/bagetter.crt) to your build machine and compare its fingerprint with BAGETTER_CERT_SHA256 from Step 4. It is a leaf server certificate for this instance's names only (CA:FALSE, no certificate signing), so adding it to a machine's trust store lets that machine trust this feed and nothing else. On an Ubuntu or Debian build machine:

scp -i <your-key.pem> ubuntu@<public-ip>:/etc/nginx/tls/bagetter.crt ./bagetter.crt
openssl x509 -noout -fingerprint -sha256 -in ./bagetter.crt
sudo install -m 0644 ./bagetter.crt /usr/local/share/ca-certificates/bagetter.crt
sudo update-ca-certificates

Then add the feed and push from your workstation or CI job, with your instance's DNS name (or your own, Step 8) in place of <your-domain> and the key from Step 4. In CI, keep the key in the pipeline's secret store rather than in a file in the repository:

dotnet nuget add source https://<your-domain>/v3/index.json --name bagetter
dotnet nuget push ./bin/Release/MyCompany.Utils.1.2.0.nupkg --source bagetter --api-key "$BAGETTER_API_KEY"
dotnet add package MyCompany.Utils --source bagetter

With a certificate from your own certificate authority (Step 8), the trust step is not needed.

Step 7: Check the security posture

A wrong key is refused for pushes and deletes, the feed and the health check stay readable, a request for a host name the instance does not serve is dropped without an answer, and nothing answers on port 80:

C=/etc/nginx/tls/bagetter.crt
curl -s --cacert $C -o /dev/null -w 'service index       -> HTTP %{http_code}\n' https://localhost/v3/index.json
curl -s --cacert $C -K <(printf 'header = "X-NuGet-ApiKey: not-the-key"\n') -X PUT -F package=@/opt/bagetter/testdata/Cloudimg.Smoke.Test.1.0.0.nupkg -o /dev/null -w 'push, wrong key     -> HTTP %{http_code}\n' https://localhost/api/v2/package
curl -s --cacert $C -X DELETE -o /dev/null -w 'delete, no key      -> HTTP %{http_code}\n' https://localhost/api/v2/package/Cloudimg.Smoke.Test/1.0.0
curl -s --cacert $C -o /dev/null -w 'health check        -> HTTP %{http_code}\n' https://localhost/healthz
curl -s --cacert $C -H 'Host: evil.example' -o /dev/null -w 'unknown host        -> %{http_code}\n' https://localhost/healthz || true
curl -s -o /dev/null -w 'plain http :80      -> %{http_code}\n' --connect-timeout 3 http://127.0.0.1/ || true
service index       -> HTTP 200
push, wrong key     -> HTTP 401
delete, no key      -> HTTP 401
health check        -> HTTP 200
unknown host        -> 000
plain http :80      -> 000

000 means no answer: nginx closes the connection for a host name it does not serve, and nothing listens on port 80.

The certificate is a leaf for the instance's names:

openssl x509 -noout -ext subjectAltName,basicConstraints,keyUsage,extendedKeyUsage -in /etc/nginx/tls/bagetter.crt
X509v3 Subject Alternative Name: 
    DNS:ec2-44-201-173-7.compute-1.amazonaws.com, IP Address:44.201.173.7, IP Address:172.31.80.97, IP Address:127.0.0.1, DNS:localhost
X509v3 Basic Constraints: critical
    CA:FALSE
X509v3 Key Usage: critical
    Digital Signature, Key Encipherment
X509v3 Extended Key Usage: 
    TLS Web Server Authentication

BaGetter runs as the unprivileged bagetter user on loopback, and BaGetter, nginx and the first boot service cannot reach the instance metadata service:

ps -o user:8=,args= -C dotnet
systemctl show bagetter nginx bagetter-firstboot -p Id -p IPAddressDeny
bagetter /usr/bin/dotnet /opt/bagetter/BaGetter.dll
IPAddressDeny=fd00:ec2::254/128 168.63.129.16/32 169.254.169.254/32
Id=bagetter.service

IPAddressDeny=fd00:ec2::254/128 168.63.129.16/32 169.254.169.254/32
Id=nginx.service

IPAddressDeny=fd00:ec2::254/128 168.63.129.16/32 169.254.169.254/32
Id=bagetter-firstboot.service

Step 8: Rotate the key, use your own DNS name

sudo bagetter-rotate-apikey replaces the push API key with a new random one, writes it to /root/bagetter-credentials.txt and restarts BaGetter; the old key stops working at once. Update the key in your CI secret store afterwards. Rotate whenever the key may have been exposed.

Point a DNS record at the instance, then set the name the feed is served as. This mints a new self signed leaf certificate for the name (and the instance's IP addresses), updates nginx and the URLs in the credentials file, and keeps the name across reboots. Give your build machines the new certificate afterwards:

sudo bagetter-set-hostname <your-domain>

To use a certificate from your own certificate authority instead, copy it over the generated pair and reload nginx (running bagetter-set-hostname again replaces it with a self signed one):

sudo install -m 0644 <your-domain>.crt /etc/nginx/tls/bagetter.crt
sudo install -m 0600 <your-domain>.key /etc/nginx/tls/bagetter.key
sudo nginx -t && sudo systemctl reload nginx

Step 9: Server components

Component Path
BaGetter application /opt/bagetter/ (read only, owner root)
Licence and build provenance /usr/share/doc/bagetter/
Database and package store /var/lib/bagetter/ (data volume: bagetter.db, packages/)
Push API key /etc/bagetter/bagetter.env (mode 0600, read by systemd)
Service unit /etc/systemd/system/bagetter.service (loopback only; settings as environment variables)
nginx site /etc/nginx/sites-available/cloudimg-bagetter
Certificate and key /etc/nginx/tls/
First boot script /usr/local/sbin/bagetter-firstboot.sh
Helpers /usr/local/sbin/bagetter-rotate-apikey, bagetter-set-hostname, bagetter-guard
Credentials /root/bagetter-credentials.txt (mode 0600)
dotnet --list-runtimes
grep -E '^(source|SDK|NuGet audit)' /usr/share/doc/bagetter/BUILD-PROVENANCE.txt
nginx -v 2>&1
Microsoft.AspNetCore.App 10.0.14 [/usr/lib/dotnet/shared/Microsoft.AspNetCore.App]
Microsoft.NETCore.App 10.0.14 [/usr/lib/dotnet/shared/Microsoft.NETCore.App]
source:      https://github.com/bagetter/BaGetter/archive/refs/tags/v1.6.5.tar.gz
SDK:         .NET SDK 10.0.114 (Ubuntu 24.04 dotnet-sdk-10.0, purged after the build)
NuGet audit: no vulnerable packages (dotnet list package --vulnerable --include-transitive)
nginx version: nginx/1.24.0 (Ubuntu)

BaGetter's settings (storage path, database, mirroring off, overwrites refused) are environment variables in bagetter.service; change one with sudo systemctl edit bagetter, using the names from the BaGetter configuration documentation. The .NET runtime is an Ubuntu package and is patched with the rest of the system. Logs: sudo journalctl -u bagetter for BaGetter, /var/log/nginx/access.log for requests (with the real client address).

Step 10: Security recommendations

  • Allow port 22 in the security group from your own networks only, and port 443 only from the networks of your build machines and developers.
  • Keep the push API key in your CI system's secret store, and rotate it (Step 8) when someone leaves or it may have been exposed: anyone with the key can publish packages your builds will install.
  • Back up /var/lib/bagetter (an EBS snapshot of the data volume is simplest).
  • Use your own DNS name and certificate (Step 8).
  • Keep the operating system patched; unattended security upgrades are on, and sudo apt-get update && sudo apt-get upgrade applies everything else.

Support and licensing

BaGetter is open source under the MIT License; there is no per user or per CPU licence fee. The licence is in /usr/share/doc/bagetter/. cloudimg is not affiliated with or endorsed by the BaGetter project. The AMI price covers cloudimg's hardening, first boot setup and support.

  • Email: support@cloudimg.co.uk
  • Website: https://www.cloudimg.co.uk
  • Support: 24/7, with a 24 hour response SLA