>
Open Source

Schedule one-off Linux jobs with at and stop abusing cron

I used to write cron entries for things that should have been one-shot jobs. A temporary firewall rule for a vendor. A service restart at 2 AM. A reminder to pull a database snapshot before a deploy. Every one of those jobs ended with me forgetting to delete the cron line after it ran, which left stale entries behind that fired against jobs that no longer existed. The at command (a small Linux utility that queues a command to run once at a specific time, then forgets about it) fixed that. It is not new, it is not obscure, and it is already installed on most systems. I just never used it until a vendor left a temporary SSH rule open for three weeks because I forgot to clean it up.

If you have ever written a cron job, run it once, and then forgot to delete it, this article is for you. The at command is the answer you did not know you already had.

What the at command actually does

Behind the at command is a deferred execution scheduler. You give it a command and a time, and it queues the job in a system spool. A background service called atd (the at daemon, a long-running process that checks the queue once a minute) wakes up periodically, pulls the job out when its time arrives, runs it, and forgets it ever existed. The job does not recur. There is no crontab entry to clean up. There is no schedule file to maintain.

Cron, by contrast, has no concept of “do this once and then stop.” Cron is built for jobs that repeat: backups every night, log rotation every Sunday, certificate renewal every 90 days. If you want a one-off task, you either write a cron entry and delete it later (and you will forget to delete it), or you use at.

The mental model is simple. If the job should run every day, every week, or every hour, use cron. If the job should run once at a specific time, use at. The two tools are not competitors. They cover different shapes of scheduling.

Installing at and getting atd running

On most modern Linux distributions, at is not installed by default. You install the package and start the daemon:

sudo apt install at      # Debian, Ubuntu, Mint
sudo dnf install at      # RHEL, CentOS, Fedora, Rocky, Alma
sudo apk add at          # Alpine
sudo pacman -S at        # Arch
sudo zypper install at   # openSUSE

The package pulls in three commands: at (queue a job), atq (list queued jobs), and atrm (remove a queued job). It also installs atd, the background service that runs the queued jobs.

After installation, enable the service so it starts on boot and run it now:

sudo systemctl enable --now atd
sudo systemctl status atd

If systemctl status atd shows active (running), the daemon is ready. If it is stopped, anything you queue with at will sit in the spool and never run, which is the most common reason people think at is broken.

Scheduling a one-off job

The basic invocation accepts a time string in many forms. Here are the patterns I actually use:

# Run a command at 2:30 AM tomorrow
echo "systemctl restart myapp" | at 02:30

# Run a command 15 minutes from now
echo "/usr/local/bin/backup.sh" | at now + 15 minutes

# Run a command at a specific date and time
echo "rm /tmp/vendor-ssh-rule.txt" | at 14:00 Jun 30 2026

# Run a command at the next 2 AM, regardless of today
echo "systemctl restart workers" | at 2am tomorrow

You can also run at interactively by typing at 02:30 and entering commands line by line, then pressing Ctrl-D on an empty line. The pipe form is what I use 90% of the time because it is one line and plays well with scripts.

A few notes on time formats. at is forgiving. midnight, noon, teatime (4 PM), now + 1 hour, tomorrow, next monday all work. If the time string is ambiguous, at picks the next future occurrence. If you want the one in the past, add the date.

Managing queued jobs

Three commands cover the full management lifecycle:

  • atq lists every job currently in the queue, with the job number, the scheduled time, and the user who queued it.
  • at -c <jobnumber> shows the full command (and environment) that will run.
  • atrm <jobnumber> removes a job you no longer want to run.
$ atq
8       Mon Jun 30 14:00:00 2026 a root
9       Tue Jul  1 02:30:00 2026 a root

$ atrm 8

The job number is what atq prints in the first column. There is no “are you sure” prompt on atrm. It is a silent delete. That is intentional, but it is also why I run atq before atrm if I am cleaning up more than one job.

Where at fits in a real workflow

The temporary-vendor-SSH case is the canonical example. You open a firewall rule for a vendor’s IP, give them access for two hours, then close the rule. With cron, you write an entry that runs iptables -D INPUT -s 1.2.3.4 -j ACCEPT in two hours, then forget to delete the cron entry. Three weeks later the cron entry fires against a vendor who already left and a rule that no longer exists. The job fails silently, the crontab gets messier, and you do not realize until you are reviewing cron during a separate incident.

With at, you queue the close-the-rule job once and it self-destructs. Two hours later the rule is gone, the spool entry is gone, and there is nothing to clean up.

The pattern generalizes. Any time you find yourself writing a cron job you intend to delete after one run, that job should have been at. A few I have used recently:

  • Restart a service after a deploy, then forget about it
  • Delete temporary backup files from /tmp after a 4-hour window
  • Re-enable a feature flag that was turned off for a debugging session
  • Send myself a Slack reminder at a specific time tomorrow
  • Shut down a test EC2 instance I am using for a benchmark

The “schedule, run once, clean up automatically” loop is what at is for. Cron is for everything else.

Trade-offs

The at command is not free. It is a separate daemon (atd) that needs to be running, which is one more service to monitor on production boxes. If atd crashes or the host reboots into a state where the daemon does not come back up, queued jobs silently fail to run, and the failure mode is the same as cron failure: nothing in the logs screams about the missing job. For mission-critical one-shots, you still want a wrapper that emails or pages on job completion.

Permission control is also worth knowing about. By default, /etc/at.allow and /etc/at.deny control who can queue jobs. On most distros, both files are empty and any user can queue. If you are running a multi-user system and want to lock at down, populate at.allow with the allowed usernames and at.deny with the rest. The daemon reads these on startup.

Migration cost is essentially zero. There is no new config file to learn, no new syntax beyond the time string, and atq/atrm work the way you expect. The cost is in the head shift: I now have to remember to ask “is this a one-off?” before reaching for cron.

If you run a single-user Linux box and you keep writing cron entries you delete the next morning, switching to at is a clear win. If you run a multi-tenant production fleet where every job needs audit logging and on-failure alerting, at is a small tool, not a replacement for a real scheduler like Airflow or a queue.

Bottom line

The at command is the answer to the question “I want to run this once at a specific time and never think about it again.” It is a 30-year-old tool that does exactly one thing, and it does that thing well. If cron is for habits, at is for sticky notes. I run a couple of at jobs a week now, mostly for temporary firewall rules and post-deploy restarts. The stale crontab entries are gone. I have not forgotten to clean up a one-shot in months.

If you only do one thing from this article, install at, start atd, and rewrite your next temporary cron entry as echo "your command" | at <time>. The rest can wait.

Leave a comment