A small self-hosted URL shortener with an optional nip.io-style DNS service.
Myurls can run two services from the same Docker image:
- Web: shorten URLs and redirect short links.
- xip DNS (optional): resolve an IPv4 address embedded in a hostname.
127.0.0.1.xip.example.com -> 127.0.0.1
127-0-0-1.xip.example.com -> 127.0.0.1
app.10-0-0-8.xip.example.com -> 10.0.0.8
- A Linux server with a public IPv4 address
- Docker Engine with the
docker composecommand - A domain name pointed at the server
- Nginx or another reverse proxy for HTTPS
- TCP and UDP port 53 open if the optional xip DNS service is enabled
Clone the repository and create the local configuration:
git clone https://github.com/fatjyc/myurls.git
cd myurls
cp .env.example .envReview .env if you need to change the local bind address or port. Replace
short.example.com in the reverse-proxy examples with your real domain.
Build the image, create the database tables, and start the web service:
./init_db.sh
docker compose up -d myurlsThe application is now available locally on the server at
http://127.0.0.1:9292.
Check its status:
docker compose ps
curl -I -H 'Host: short.example.com' http://127.0.0.1:9292/The container only binds the web service to localhost by default. Put Nginx, Caddy, or another reverse proxy in front of it.
Minimal Nginx configuration:
server {
listen 80;
listen [::]:80;
server_name short.example.com;
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:9292;
}
}After enabling the site, issue a certificate. For example, with Certbot:
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx -d short.example.comThe DNS service is optional and is placed behind the Compose xip profile.
Configure it in .env:
XIP_WEB_HOST=xip.example.com
DNS_ZONE=xip.example.com
DNS_DEFAULT_IP=203.0.113.10
DNS_NAMESERVER=short.example.comDNS_ZONEis the delegated hostname suffix.DNS_DEFAULT_IPis the server's public IPv4 address and is returned for the zone root, such asxip.example.com.DNS_NAMESERVERmust resolve to the server running this DNS service.XIP_WEB_HOSTmakes the web application show the built-in xip documentation page when that hostname is opened in a browser.
Start both services:
docker compose --profile xip up -d --buildThe DNS container listens on both TCP and UDP port 53. Ensure those ports are open in the operating-system firewall and cloud-provider firewall.
At the DNS provider for example.com, add an NS record:
Type: NS
Name: xip
Target: short.example.com
Do not add a wildcard A record such as *.xip.example.com. A wildcard A record
would return one fixed address and prevent queries from reaching the dynamic
DNS service.
Once the delegation has propagated, verify both supported formats:
dig 127.0.0.1.xip.example.com A +short
dig 127-0-0-1.xip.example.com A +shortBoth commands should return:
127.0.0.1
To serve the xip documentation page, add xip.example.com to the reverse proxy
and point it to the same web container:
server {
listen 80;
listen [::]:80;
server_name xip.example.com;
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:9292;
}
}Then issue its HTTPS certificate:
sudo certbot --nginx -d xip.example.com| Variable | Default | Description |
|---|---|---|
MYURLS_BIND |
127.0.0.1 |
Host address used for the web port binding. |
MYURLS_PORT |
9292 |
Host port used for the web service. |
XIP_WEB_HOST |
xip.1gb.xyz |
Hostname that renders the xip documentation page. |
DNS_BIND |
0.0.0.0 |
Address used for TCP/UDP port 53 bindings. |
DNS_PORT |
8053 |
DNS port inside the container. |
DNS_ZONE |
xip.1gb.xyz |
Authoritative DNS suffix. |
DNS_DEFAULT_IP |
127.0.0.1 |
A record returned for the DNS zone root. |
DNS_NAMESERVER |
localhost |
Nameserver hostname returned by the DNS service. |
DNS_TTL |
300 |
DNS response TTL in seconds. |
Production data is stored in db/production.sqlite3 and mounted into the web
container. init_db.sh runs migrations without deleting existing data.
Back up the database before upgrades:
cp db/production.sqlite3 "db/production.sqlite3.$(date +%Y%m%d-%H%M%S).bak"Apply new migrations and restart:
./init_db.sh
docker compose up -d --build myurls# Follow web logs
docker compose logs -f myurls
# Follow DNS logs
docker compose --profile xip logs -f xip
# Restart the web service
docker compose restart myurls
# Stop everything, preserving the database
docker compose --profile xip downThe default Compose file keeps web and DNS as separate services so either can be upgraded or restarted independently. On a small server they can share one container through the included supervisor:
services:
myurls:
command: bundle exec ruby bin/myurls-server
environment:
- APP_ENV=production
- RACK_ENV=production
- ENABLE_XIP_DNS=true
- DNS_PORT=8053
- DNS_ZONE=xip.example.com
- DNS_DEFAULT_IP=203.0.113.10
- DNS_NAMESERVER=short.example.com
ports:
- "127.0.0.1:9292:9292"
- "53:8053/tcp"
- "53:8053/udp"The supervisor forwards termination signals and stops the container if either Unicorn or the DNS process exits unexpectedly, allowing the restart policy to recover both services.
Run the test suite inside the production Ruby environment:
docker compose build myurls
docker compose run --rm -e APP_ENV=test -e RACK_ENV=test myurls bundle exec rake testCheck that the container is running and port 9292 is listening:
docker compose ps
docker compose logs --tail=100 myurls
curl -I http://127.0.0.1:9292/Remove any wildcard A record for the delegated xip zone. Only the NS delegation should exist at the parent DNS provider.
Confirm the NS delegation, TCP/UDP port 53 firewall rules, and public address:
dig xip.example.com NS
dig @203.0.113.10 127-0-0-1.xip.example.com A +shortSome proxy applications use that range for fake-IP DNS. Add the xip zone to the proxy's real-IP/bypass list and route the zone directly.
Upgrade Docker and the host operating system. As a temporary workaround on a trusted server, add the following to the affected service:
security_opt:
- seccomp:unconfinedThis disables the container's seccomp filter, so upgrading Docker is the safer long-term solution.